Update reference docs: fix event names, add demos, document sx-boost target
- Remove sx:afterSettle (not dispatched), rename sx:sendError → sx:requestError - Add sx:clientRoute event (Phase 3 client-side routing) - Add working demos for all 10 events (afterRequest, afterSwap, requestError, clientRoute, sseOpen, sseMessage, sseError were missing demos) - Update sx-boost docs: configurable target selector, client routing behavior - Remove app-specific nav logic from orchestration.sx, use sx:clientRoute event - Pass page content deps to sx_response for component loading after server fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -737,6 +737,26 @@ document.body.addEventListener('click', function (e) {
|
||||
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Client-side route nav selection
|
||||
// - Updates aria-selected on sub-nav links after client-side routing
|
||||
// - Scoped to <nav> elements (top-level section links are outside <nav>)
|
||||
// ============================================================================
|
||||
|
||||
document.body.addEventListener('sx:clientRoute', function (e) {
|
||||
var pathname = e.detail && e.detail.pathname;
|
||||
if (!pathname) return;
|
||||
// Deselect all sub-nav links (inside <nav> elements)
|
||||
document.querySelectorAll('nav a[aria-selected]').forEach(function (a) {
|
||||
a.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
// Select the matching link
|
||||
document.querySelectorAll('nav a[href="' + pathname + '"]').forEach(function (a) {
|
||||
a.setAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Scrolling menu arrow visibility (replaces hyperscript scroll/load handlers)
|
||||
// Elements with data-scroll-arrows="arrow-class" show/hide arrows on overflow.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// =========================================================================
|
||||
|
||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
||||
var SX_VERSION = "2026-03-06T22:18:52Z";
|
||||
var SX_VERSION = "2026-03-06T23:02:44Z";
|
||||
|
||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||
@@ -1850,7 +1850,7 @@ return postSwap(target); });
|
||||
var clientRouted = false;
|
||||
if (isSxTruthy(isGetLink)) {
|
||||
logInfo((String("sx:route trying ") + String(get(verbInfo, "url"))));
|
||||
clientRouted = tryClientRoute(urlPathname(get(verbInfo, "url")));
|
||||
clientRouted = tryClientRoute(urlPathname(get(verbInfo, "url")), domGetAttr(el, "sx-target"));
|
||||
}
|
||||
return (isSxTruthy(clientRouted) ? (browserPushState(get(verbInfo, "url")), browserScrollTo(0, 0)) : ((isSxTruthy(isGetLink) ? logInfo((String("sx:route server fetch ") + String(get(verbInfo, "url")))) : NIL), (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(function() { return executeRequest(el, verbInfo, NIL); }, get(mods, "delay")))) : executeRequest(el, verbInfo, NIL))));
|
||||
})()) : NIL);
|
||||
@@ -1902,10 +1902,12 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
|
||||
var processBoosted = function(root) { return forEach(function(container) { return boostDescendants(container); }, domQueryAll(sxOr(root, domBody()), "[sx-boost]")); };
|
||||
|
||||
// boost-descendants
|
||||
var boostDescendants = function(container) { { var _c = domQueryAll(container, "a[href]"); for (var _i = 0; _i < _c.length; _i++) { var link = _c[_i]; if (isSxTruthy((isSxTruthy(!isSxTruthy(isProcessed(link, "boost"))) && shouldBoostLink(link)))) {
|
||||
var boostDescendants = function(container) { return (function() {
|
||||
var boostTarget = domGetAttr(container, "sx-boost");
|
||||
{ var _c = domQueryAll(container, "a[href]"); for (var _i = 0; _i < _c.length; _i++) { var link = _c[_i]; if (isSxTruthy((isSxTruthy(!isSxTruthy(isProcessed(link, "boost"))) && shouldBoostLink(link)))) {
|
||||
markProcessed(link, "boost");
|
||||
if (isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-target")))) {
|
||||
domSetAttr(link, "sx-target", "#main-panel");
|
||||
if (isSxTruthy((isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-target"))) && isSxTruthy(boostTarget) && !isSxTruthy((boostTarget == "true"))))) {
|
||||
domSetAttr(link, "sx-target", boostTarget);
|
||||
}
|
||||
if (isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-swap")))) {
|
||||
domSetAttr(link, "sx-swap", "innerHTML");
|
||||
@@ -1915,20 +1917,21 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
|
||||
}
|
||||
bindClientRouteLink(link, domGetAttr(link, "href"));
|
||||
} } }
|
||||
return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isSxTruthy(isProcessed(form, "boost"))) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), (function() {
|
||||
return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isSxTruthy(isProcessed(form, "boost"))) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), (function() {
|
||||
var method = upper(sxOr(domGetAttr(form, "method"), "GET"));
|
||||
var action = sxOr(domGetAttr(form, "action"), browserLocationHref());
|
||||
if (isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-target")))) {
|
||||
domSetAttr(form, "sx-target", "#main-panel");
|
||||
if (isSxTruthy((isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-target"))) && isSxTruthy(boostTarget) && !isSxTruthy((boostTarget == "true"))))) {
|
||||
domSetAttr(form, "sx-target", boostTarget);
|
||||
}
|
||||
if (isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-swap")))) {
|
||||
domSetAttr(form, "sx-swap", "innerHTML");
|
||||
}
|
||||
return bindBoostForm(form, method, action);
|
||||
})()) : NIL); }, domQueryAll(container, "form")); };
|
||||
})()) : NIL); }, domQueryAll(container, "form"));
|
||||
})(); };
|
||||
|
||||
// try-client-route
|
||||
var tryClientRoute = function(pathname) { return (function() {
|
||||
var tryClientRoute = function(pathname, targetSel) { return (function() {
|
||||
var match = findMatchingRoute(pathname, _pageRoutes);
|
||||
return (isSxTruthy(isNil(match)) ? (logInfo((String("sx:route no match (") + String(len(_pageRoutes)) + String(" routes) ") + String(pathname))), false) : (isSxTruthy(get(match, "has-data")) ? (logInfo((String("sx:route server (has data) ") + String(pathname))), false) : (function() {
|
||||
var contentSrc = get(match, "content");
|
||||
@@ -1938,8 +1941,8 @@ return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isSxTruthy(isPro
|
||||
var env = merge(closure, params);
|
||||
var rendered = tryEvalContent(contentSrc, env);
|
||||
return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (function() {
|
||||
var target = domQueryById("main-panel");
|
||||
return (isSxTruthy(isNil(target)) ? (logWarn("sx:route #main-panel not found"), false) : (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), logInfo((String("sx:route client ") + String(pathname))), true));
|
||||
var target = (isSxTruthy((isSxTruthy(targetSel) && !isSxTruthy((targetSel == "true")))) ? domQuery(targetSel) : NIL);
|
||||
return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname))), true));
|
||||
})());
|
||||
})());
|
||||
})()));
|
||||
@@ -2026,14 +2029,16 @@ return bindInlineHandlers(root); };
|
||||
|
||||
// handle-popstate
|
||||
var handlePopstate = function(scrollY) { return (function() {
|
||||
var main = domQueryById("main-panel");
|
||||
var boostEl = domQuery("[sx-boost]");
|
||||
var url = browserLocationHref();
|
||||
return (isSxTruthy(main) ? (function() {
|
||||
return (isSxTruthy(boostEl) ? (function() {
|
||||
var targetSel = domGetAttr(boostEl, "sx-boost");
|
||||
var target = (isSxTruthy((isSxTruthy(targetSel) && !isSxTruthy((targetSel == "true")))) ? domQuery(targetSel) : NIL);
|
||||
var pathname = urlPathname(url);
|
||||
return (isSxTruthy(tryClientRoute(pathname)) ? browserScrollTo(0, scrollY) : (function() {
|
||||
var headers = buildRequestHeaders(main, loadedComponentNames(), _cssHash);
|
||||
return fetchAndRestore(main, url, headers, scrollY);
|
||||
})());
|
||||
return (isSxTruthy(target) ? (isSxTruthy(tryClientRoute(pathname, targetSel)) ? browserScrollTo(0, scrollY) : (function() {
|
||||
var headers = buildRequestHeaders(target, loadedComponentNames(), _cssHash);
|
||||
return fetchAndRestore(target, url, headers, scrollY);
|
||||
})()) : NIL);
|
||||
})() : NIL);
|
||||
})(); };
|
||||
|
||||
@@ -3106,7 +3111,7 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
}
|
||||
return sxRenderWithEnv(source, merged);
|
||||
} catch (e) {
|
||||
console.error("[sx-ref] sx:route eval error for:", source, e);
|
||||
logInfo("sx:route eval miss: " + (e && e.message ? e.message : e));
|
||||
return NIL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,14 +456,18 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
|
||||
|
||||
|
||||
|
||||
def components_for_request(source: str = "") -> str:
|
||||
def components_for_request(source: str = "",
|
||||
extra_names: set[str] | None = None) -> str:
|
||||
"""Return defcomp/defmacro source for definitions the client doesn't have yet.
|
||||
|
||||
Reads the ``SX-Components`` header (comma-separated component names
|
||||
like ``~card,~nav-item``) and returns only the definitions the client
|
||||
is missing. If *source* is provided, only sends components needed
|
||||
for that source (plus transitive deps). If the header is absent,
|
||||
returns all needed defs.
|
||||
for that source (plus transitive deps).
|
||||
|
||||
*extra_names* — additional component names to include beyond what
|
||||
*source* references. Used by defpage to send components the page's
|
||||
content expression needs for client-side routing.
|
||||
"""
|
||||
from quart import request
|
||||
from .jinja_bridge import _COMPONENT_ENV
|
||||
@@ -477,6 +481,12 @@ def components_for_request(source: str = "") -> str:
|
||||
else:
|
||||
needed = None # all
|
||||
|
||||
# Merge in extra names (e.g. from page content expression deps)
|
||||
if extra_names and needed is not None:
|
||||
needed = needed | extra_names
|
||||
elif extra_names:
|
||||
needed = extra_names
|
||||
|
||||
loaded_raw = request.headers.get("SX-Components", "")
|
||||
loaded = set(loaded_raw.split(",")) if loaded_raw else set()
|
||||
|
||||
@@ -510,7 +520,8 @@ def components_for_request(source: str = "") -> str:
|
||||
|
||||
|
||||
def sx_response(source: str, status: int = 200,
|
||||
headers: dict | None = None):
|
||||
headers: dict | None = None,
|
||||
extra_component_names: set[str] | None = None):
|
||||
"""Return an s-expression wire-format response.
|
||||
|
||||
Takes a raw sx string::
|
||||
@@ -520,6 +531,10 @@ def sx_response(source: str, status: int = 200,
|
||||
For SX requests, missing component definitions are prepended as a
|
||||
``<script type="text/sx" data-components>`` block so the client
|
||||
can process them before rendering OOB content.
|
||||
|
||||
*extra_component_names* — additional component names to include beyond
|
||||
what *source* references. Used by defpage to send components the page's
|
||||
content expression needs for client-side routing.
|
||||
"""
|
||||
from quart import request, Response
|
||||
|
||||
@@ -535,7 +550,7 @@ def sx_response(source: str, status: int = 200,
|
||||
# For SX requests, prepend missing component definitions
|
||||
comp_defs = ""
|
||||
if request.headers.get("SX-Request"):
|
||||
comp_defs = components_for_request(source)
|
||||
comp_defs = components_for_request(source, extra_names=extra_component_names)
|
||||
if comp_defs:
|
||||
body = (f'<script type="text/sx" data-components>'
|
||||
f'{comp_defs}</script>\n{body}')
|
||||
|
||||
@@ -279,13 +279,25 @@ async def execute_page(
|
||||
is_htmx = is_htmx_request()
|
||||
|
||||
if is_htmx:
|
||||
# Compute content expression deps so the server sends component
|
||||
# definitions the client needs for future client-side routing
|
||||
extra_deps: set[str] | None = None
|
||||
if page_def.content_expr is not None and page_def.data_expr is None:
|
||||
from .deps import components_needed
|
||||
from .parser import serialize
|
||||
try:
|
||||
content_src = serialize(page_def.content_expr)
|
||||
extra_deps = components_needed(content_src, get_component_env())
|
||||
except Exception:
|
||||
pass # non-critical — client will just fall back to server
|
||||
|
||||
return sx_response(await oob_page_sx(
|
||||
oobs=oob_headers if oob_headers else "",
|
||||
filter=filter_sx,
|
||||
aside=aside_sx,
|
||||
content=content_sx,
|
||||
menu=menu_sx,
|
||||
))
|
||||
), extra_component_names=extra_deps)
|
||||
else:
|
||||
return await full_page_sx(
|
||||
tctx,
|
||||
|
||||
@@ -2632,7 +2632,7 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
}
|
||||
return sxRenderWithEnv(source, merged);
|
||||
} catch (e) {
|
||||
console.error("[sx-ref] sx:route eval error for:", source, e);
|
||||
logInfo("sx:route eval miss: " + (e && e.message ? e.message : e));
|
||||
return NIL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +397,9 @@
|
||||
(when is-get-link
|
||||
(log-info (str "sx:route trying " (get verbInfo "url")))
|
||||
(set! client-routed
|
||||
(try-client-route (url-pathname (get verbInfo "url")))))
|
||||
(try-client-route
|
||||
(url-pathname (get verbInfo "url"))
|
||||
(dom-get-attr el "sx-target"))))
|
||||
(if client-routed
|
||||
(do
|
||||
(browser-push-state (get verbInfo "url"))
|
||||
@@ -507,48 +509,54 @@
|
||||
|
||||
(define boost-descendants
|
||||
(fn (container)
|
||||
;; Boost links and forms within a container
|
||||
;; Links get sx-get, forms get sx-post/sx-get
|
||||
(for-each
|
||||
(fn (link)
|
||||
(when (and (not (is-processed? link "boost"))
|
||||
(should-boost-link? link))
|
||||
(mark-processed! link "boost")
|
||||
;; Set default sx-target if not specified
|
||||
(when (not (dom-has-attr? link "sx-target"))
|
||||
(dom-set-attr link "sx-target" "#main-panel"))
|
||||
(when (not (dom-has-attr? link "sx-swap"))
|
||||
(dom-set-attr link "sx-swap" "innerHTML"))
|
||||
(when (not (dom-has-attr? link "sx-push-url"))
|
||||
(dom-set-attr link "sx-push-url" "true"))
|
||||
(bind-client-route-link link (dom-get-attr link "href"))))
|
||||
(dom-query-all container "a[href]"))
|
||||
(for-each
|
||||
(fn (form)
|
||||
(when (and (not (is-processed? form "boost"))
|
||||
(should-boost-form? form))
|
||||
(mark-processed! form "boost")
|
||||
(let ((method (upper (or (dom-get-attr form "method") "GET")))
|
||||
(action (or (dom-get-attr form "action")
|
||||
(browser-location-href))))
|
||||
(when (not (dom-has-attr? form "sx-target"))
|
||||
(dom-set-attr form "sx-target" "#main-panel"))
|
||||
(when (not (dom-has-attr? form "sx-swap"))
|
||||
(dom-set-attr form "sx-swap" "innerHTML"))
|
||||
(bind-boost-form form method action))))
|
||||
(dom-query-all container "form"))))
|
||||
;; Boost links and forms within a container.
|
||||
;; The sx-boost attribute value is the default target selector
|
||||
;; for boosted descendants (e.g. sx-boost="#main-panel").
|
||||
(let ((boost-target (dom-get-attr container "sx-boost")))
|
||||
(for-each
|
||||
(fn (link)
|
||||
(when (and (not (is-processed? link "boost"))
|
||||
(should-boost-link? link))
|
||||
(mark-processed! link "boost")
|
||||
;; Inherit target from boost container if not specified
|
||||
(when (and (not (dom-has-attr? link "sx-target"))
|
||||
boost-target (not (= boost-target "true")))
|
||||
(dom-set-attr link "sx-target" boost-target))
|
||||
(when (not (dom-has-attr? link "sx-swap"))
|
||||
(dom-set-attr link "sx-swap" "innerHTML"))
|
||||
(when (not (dom-has-attr? link "sx-push-url"))
|
||||
(dom-set-attr link "sx-push-url" "true"))
|
||||
(bind-client-route-link link (dom-get-attr link "href"))))
|
||||
(dom-query-all container "a[href]"))
|
||||
(for-each
|
||||
(fn (form)
|
||||
(when (and (not (is-processed? form "boost"))
|
||||
(should-boost-form? form))
|
||||
(mark-processed! form "boost")
|
||||
(let ((method (upper (or (dom-get-attr form "method") "GET")))
|
||||
(action (or (dom-get-attr form "action")
|
||||
(browser-location-href))))
|
||||
(when (and (not (dom-has-attr? form "sx-target"))
|
||||
boost-target (not (= boost-target "true")))
|
||||
(dom-set-attr form "sx-target" boost-target))
|
||||
(when (not (dom-has-attr? form "sx-swap"))
|
||||
(dom-set-attr form "sx-swap" "innerHTML"))
|
||||
(bind-boost-form form method action))))
|
||||
(dom-query-all container "form")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Client-side routing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; No app-specific nav update here — apps handle sx:clientRoute event.
|
||||
|
||||
|
||||
(define try-client-route
|
||||
(fn (pathname)
|
||||
(fn (pathname target-sel)
|
||||
;; Try to render a page client-side. Returns true if successful, false otherwise.
|
||||
;; target-sel is the CSS selector for the swap target (from sx-boost value).
|
||||
;; Only works for pages without :data dependencies.
|
||||
;; Uses try-eval-content which catches errors — if a component is missing,
|
||||
;; eval fails and we fall back to server fetch transparently.
|
||||
(let ((match (find-matching-route pathname _page-routes)))
|
||||
(if (nil? match)
|
||||
(do (log-info (str "sx:route no match (" (len _page-routes) " routes) " pathname)) false)
|
||||
@@ -563,15 +571,19 @@
|
||||
(rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
|
||||
(let ((target (dom-query-by-id "main-panel")))
|
||||
(let ((target (if (and target-sel (not (= target-sel "true")))
|
||||
(dom-query target-sel)
|
||||
nil)))
|
||||
(if (nil? target)
|
||||
(do (log-warn "sx:route #main-panel not found") false)
|
||||
(do (log-warn (str "sx:route target not found: " target-sel)) false)
|
||||
(do
|
||||
(dom-set-text-content target "")
|
||||
(dom-append target rendered)
|
||||
(hoist-head-elements-full target)
|
||||
(process-elements target)
|
||||
(sx-hydrate-elements target)
|
||||
(dom-dispatch target "sx:clientRoute"
|
||||
(dict "pathname" pathname))
|
||||
(log-info (str "sx:route client " pathname))
|
||||
true))))))))))))
|
||||
|
||||
@@ -733,16 +745,22 @@
|
||||
(define handle-popstate
|
||||
(fn (scrollY)
|
||||
;; Handle browser back/forward navigation.
|
||||
;; Derive target from the nearest [sx-boost] container.
|
||||
;; Try client-side route first, fall back to server fetch.
|
||||
(let ((main (dom-query-by-id "main-panel"))
|
||||
(let ((boost-el (dom-query "[sx-boost]"))
|
||||
(url (browser-location-href)))
|
||||
(when main
|
||||
(let ((pathname (url-pathname url)))
|
||||
(if (try-client-route pathname)
|
||||
(browser-scroll-to 0 scrollY)
|
||||
(let ((headers (build-request-headers main
|
||||
(loaded-component-names) _css-hash)))
|
||||
(fetch-and-restore main url headers scrollY))))))))
|
||||
(when boost-el
|
||||
(let ((target-sel (dom-get-attr boost-el "sx-boost"))
|
||||
(target (if (and target-sel (not (= target-sel "true")))
|
||||
(dom-query target-sel)
|
||||
nil))
|
||||
(pathname (url-pathname url)))
|
||||
(when target
|
||||
(if (try-client-route pathname target-sel)
|
||||
(browser-scroll-to 0 scrollY)
|
||||
(let ((headers (build-request-headers target
|
||||
(loaded-component-names) _css-hash)))
|
||||
(fetch-and-restore target url headers scrollY)))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -795,7 +813,7 @@
|
||||
;; cross-origin
|
||||
;; success-fn: (fn (resp-ok status get-header text) ...)
|
||||
;; error-fn: (fn (err) ...)
|
||||
;; (fetch-location url) → fetch URL and swap to #main-panel
|
||||
;; (fetch-location url) → fetch URL and swap to boost target
|
||||
;; (fetch-and-restore main url headers scroll-y) → popstate fetch+swap
|
||||
;; (fetch-preload url headers cache) → preload into cache
|
||||
;;
|
||||
|
||||
@@ -113,7 +113,7 @@ BEHAVIOR_ATTRS = [
|
||||
("sx-media", "Only enable this element when the media query matches", True),
|
||||
("sx-disable", "Disable sx processing on this element and its children", True),
|
||||
("sx-on:*", "Inline event handler — e.g. sx-on:click runs JavaScript on event", True),
|
||||
("sx-boost", "Progressively enhance all links and forms in a container with AJAX navigation", True),
|
||||
("sx-boost", "Progressively enhance all links and forms in a container with AJAX navigation. Value can be a target selector.", 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),
|
||||
@@ -171,10 +171,10 @@ EVENTS = [
|
||||
("sx:beforeRequest", "Fired before an sx request is issued. Call preventDefault() to cancel."),
|
||||
("sx:afterRequest", "Fired after a successful sx response is received."),
|
||||
("sx:afterSwap", "Fired after the response has been swapped into the DOM."),
|
||||
("sx:afterSettle", "Fired after the DOM has settled (scripts executed, etc)."),
|
||||
("sx:responseError", "Fired on HTTP error responses (4xx, 5xx)."),
|
||||
("sx:sendError", "Fired when the request fails to send (network error)."),
|
||||
("sx:requestError", "Fired when the request fails to send (network error, abort)."),
|
||||
("sx:validationFailed", "Fired when sx-validate blocks a request due to invalid form data."),
|
||||
("sx:clientRoute", "Fired after successful client-side routing (no server request)."),
|
||||
("sx:sseOpen", "Fired when an SSE connection is established."),
|
||||
("sx:sseMessage", "Fired when an SSE message is received and swapped."),
|
||||
("sx:sseError", "Fired when an SSE connection encounters an error."),
|
||||
@@ -585,7 +585,7 @@ EVENT_DETAILS: dict[str, dict] = {
|
||||
"sx:afterRequest": {
|
||||
"description": (
|
||||
"Fired on the triggering element after a successful sx response is received, "
|
||||
"before the swap happens. The response data is available on event.detail. "
|
||||
"before the swap happens. event.detail contains the response status. "
|
||||
"Use this for logging, analytics, or pre-swap side effects."
|
||||
),
|
||||
"example": (
|
||||
@@ -595,42 +595,27 @@ EVENT_DETAILS: dict[str, dict] = {
|
||||
' :sx-on:sx:afterRequest "console.log(\'Response received\', event.detail)"\n'
|
||||
' "Load data")'
|
||||
),
|
||||
"demo": "ref-event-after-request-demo",
|
||||
},
|
||||
"sx:afterSwap": {
|
||||
"description": (
|
||||
"Fired after the response content has been swapped into the DOM. "
|
||||
"The new content is in place but scripts may not have executed yet. "
|
||||
"Use this to initialize UI on newly inserted content."
|
||||
"Fired on the triggering element after the response content has been "
|
||||
"swapped into the DOM. event.detail contains the target element and swap "
|
||||
"style. Use this to initialize UI on newly inserted content."
|
||||
),
|
||||
"example": (
|
||||
';; Initialize tooltips on new content\n'
|
||||
'(div :sx-on:sx:afterSwap "initTooltips(this)"\n'
|
||||
' (button :sx-get "/api/items"\n'
|
||||
' :sx-target "#item-list"\n'
|
||||
' "Load items")\n'
|
||||
' (div :id "item-list"))'
|
||||
';; Run code after content is swapped in\n'
|
||||
'(button :sx-get "/api/items"\n'
|
||||
' :sx-target "#item-list"\n'
|
||||
' :sx-on:sx:afterSwap "console.log(\'Swapped into\', event.detail.target)"\n'
|
||||
' "Load items")'
|
||||
),
|
||||
},
|
||||
"sx:afterSettle": {
|
||||
"description": (
|
||||
"Fired after the DOM has fully settled — all scripts executed, transitions "
|
||||
"complete. This is the safest point to run code that depends on the final "
|
||||
"state of the DOM after a swap."
|
||||
),
|
||||
"example": (
|
||||
';; Scroll to new content after settle\n'
|
||||
'(div :sx-on:sx:afterSettle "document.getElementById(\'new-item\').scrollIntoView()"\n'
|
||||
' (button :sx-get "/api/append"\n'
|
||||
' :sx-target "#list" :sx-swap "beforeend"\n'
|
||||
' "Add item")\n'
|
||||
' (div :id "list"))'
|
||||
),
|
||||
"demo": "ref-event-after-settle-demo",
|
||||
"demo": "ref-event-after-swap-demo",
|
||||
},
|
||||
"sx:responseError": {
|
||||
"description": (
|
||||
"Fired when the server responds with an HTTP error (4xx or 5xx). "
|
||||
"event.detail contains the status code and response. "
|
||||
"event.detail contains the status code and response text. "
|
||||
"Use this for error handling, showing notifications, or retry logic."
|
||||
),
|
||||
"example": (
|
||||
@@ -643,21 +628,22 @@ EVENT_DETAILS: dict[str, dict] = {
|
||||
),
|
||||
"demo": "ref-event-response-error-demo",
|
||||
},
|
||||
"sx:sendError": {
|
||||
"sx:requestError": {
|
||||
"description": (
|
||||
"Fired when the request fails to send — typically a network error, "
|
||||
"DNS failure, or CORS issue. Unlike sx:responseError, no HTTP response "
|
||||
"was received at all."
|
||||
"was received at all. Aborted requests (e.g. from sx-sync) do not fire this event."
|
||||
),
|
||||
"example": (
|
||||
';; Handle network failures\n'
|
||||
'(div :sx-on:sx:sendError "this.querySelector(\'.status\').textContent = \'Offline\'"\n'
|
||||
'(div :sx-on:sx:requestError "this.querySelector(\'.status\').textContent = \'Offline\'"\n'
|
||||
' (button :sx-get "/api/data"\n'
|
||||
' :sx-target "#result"\n'
|
||||
' "Load")\n'
|
||||
' (span :class "status")\n'
|
||||
' (div :id "result"))'
|
||||
),
|
||||
"demo": "ref-event-request-error-demo",
|
||||
},
|
||||
"sx:validationFailed": {
|
||||
"description": (
|
||||
@@ -676,6 +662,29 @@ EVENT_DETAILS: dict[str, dict] = {
|
||||
),
|
||||
"demo": "ref-event-validation-failed-demo",
|
||||
},
|
||||
"sx:clientRoute": {
|
||||
"description": (
|
||||
"Fired on the swap target after successful client-side routing. "
|
||||
"No server request was made — the page was rendered entirely in the browser "
|
||||
"from component definitions the client already has. "
|
||||
"event.detail contains the pathname. Use this to update navigation state, "
|
||||
"analytics, or other side effects that should run on client-only navigation. "
|
||||
"The event bubbles, so you can listen on document.body."
|
||||
),
|
||||
"example": (
|
||||
';; Pages with no :data are client-routable.\n'
|
||||
';; sx-boost containers try client routing first.\n'
|
||||
';; On success, sx:clientRoute fires on the swap target.\n'
|
||||
'(nav :sx-boost "#main-panel"\n'
|
||||
' (a :href "/essays/" "Essays")\n'
|
||||
' (a :href "/plans/" "Plans"))\n'
|
||||
'\n'
|
||||
';; Listen in body.js:\n'
|
||||
';; document.body.addEventListener("sx:clientRoute",\n'
|
||||
';; function(e) { updateNav(e.detail.pathname); })'
|
||||
),
|
||||
"demo": "ref-event-client-route-demo",
|
||||
},
|
||||
"sx:sseOpen": {
|
||||
"description": (
|
||||
"Fired when a Server-Sent Events connection is successfully established. "
|
||||
@@ -688,6 +697,7 @@ EVENT_DETAILS: dict[str, dict] = {
|
||||
' (span :class "status" "Connecting...")\n'
|
||||
' (div :id "messages"))'
|
||||
),
|
||||
"demo": "ref-event-sse-open-demo",
|
||||
},
|
||||
"sx:sseMessage": {
|
||||
"description": (
|
||||
@@ -698,10 +708,10 @@ EVENT_DETAILS: dict[str, dict] = {
|
||||
';; Count received messages\n'
|
||||
'(div :sx-sse "/api/stream"\n'
|
||||
' :sx-sse-swap "update"\n'
|
||||
' :sx-on:sx:sseMessage "this.dataset.count = (parseInt(this.dataset.count||0)+1); this.querySelector(\'.count\').textContent = this.dataset.count"\n'
|
||||
' (span :class "count" "0") " messages received"\n'
|
||||
' (div :id "stream-content"))'
|
||||
' :sx-on:sx:sseMessage "this.dataset.count = (parseInt(this.dataset.count||0)+1)"\n'
|
||||
' (span :class "count" "0") " messages received")'
|
||||
),
|
||||
"demo": "ref-event-sse-message-demo",
|
||||
},
|
||||
"sx:sseError": {
|
||||
"description": (
|
||||
@@ -715,6 +725,7 @@ EVENT_DETAILS: dict[str, dict] = {
|
||||
' (span :class "status" "Connecting...")\n'
|
||||
' (div :id "messages"))'
|
||||
),
|
||||
"demo": "ref-event-sse-error-demo",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1200,14 +1211,22 @@ ATTR_DETAILS: dict[str, dict] = {
|
||||
"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."
|
||||
"No explicit sx-* attributes needed on each link or form — just place sx-boost on a container. "
|
||||
'The attribute value can be a CSS selector (e.g. sx-boost="#main-panel") to set '
|
||||
"the default swap target for all boosted descendants. If set to \"true\", "
|
||||
"each link/form must specify its own sx-target. "
|
||||
"Pure pages (no server data dependencies) are rendered client-side without a server request."
|
||||
),
|
||||
"demo": "ref-boost-demo",
|
||||
"example": (
|
||||
'(nav :sx-boost "true"\n'
|
||||
';; Boost with configurable target\n'
|
||||
'(nav :sx-boost "#main-panel"\n'
|
||||
' (a :href "/docs/introduction" "Introduction")\n'
|
||||
' (a :href "/docs/components" "Components")\n'
|
||||
' (a :href "/docs/evaluator" "Evaluator"))'
|
||||
' (a :href "/docs/evaluator" "Evaluator"))\n'
|
||||
'\n'
|
||||
';; All links swap into #main-panel automatically.\n'
|
||||
';; Pure pages render client-side (no server request).'
|
||||
),
|
||||
},
|
||||
"sx-preload": {
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
|
||||
(defcomp ~reference-events-content (&key table)
|
||||
(~doc-page :title "Events"
|
||||
(p :class "text-stone-600 mb-6"
|
||||
"sx fires custom DOM events at various points in the request lifecycle. "
|
||||
"Listen for them with sx-on:* attributes or addEventListener. "
|
||||
"Client-side routing fires sx:clientRoute instead of request lifecycle events.")
|
||||
table))
|
||||
|
||||
(defcomp ~reference-js-api-content (&key table)
|
||||
|
||||
@@ -424,7 +424,8 @@
|
||||
: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.")))
|
||||
"These links use AJAX navigation via sx-boost — no sx-get needed on each link. "
|
||||
"Set the value to a CSS selector (e.g. sx-boost=\"#main-panel\") to configure the default swap target for all descendants.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-preload
|
||||
@@ -727,19 +728,42 @@
|
||||
"Request is cancelled via preventDefault() if the input is empty.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx:afterSettle event demo
|
||||
;; sx:afterRequest event demo
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-event-after-settle-demo ()
|
||||
(defcomp ~ref-event-after-request-demo ()
|
||||
(div :class "space-y-3"
|
||||
(button
|
||||
:sx-get "/reference/api/time"
|
||||
:sx-target "#ref-evt-ar-result"
|
||||
:sx-swap "innerHTML"
|
||||
:sx-on:sx:afterRequest "document.getElementById('ref-evt-ar-log').textContent = 'Response status: ' + (event.detail ? event.detail.status : '?')"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||
"Load (logs after response)")
|
||||
(div :id "ref-evt-ar-log"
|
||||
:class "p-2 rounded bg-emerald-50 text-emerald-700 text-sm"
|
||||
"Event log will appear here.")
|
||||
(div :id "ref-evt-ar-result"
|
||||
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
|
||||
"Click to load — afterRequest fires before the swap.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx:afterSwap event demo
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-event-after-swap-demo ()
|
||||
(div :class "space-y-3"
|
||||
(button
|
||||
:sx-get "/reference/api/swap-item"
|
||||
:sx-target "#ref-evt-settle-list"
|
||||
:sx-target "#ref-evt-as-list"
|
||||
:sx-swap "beforeend"
|
||||
:sx-on:sx:afterSettle "var items = document.querySelectorAll('#ref-evt-settle-list > div'); if (items.length) items[items.length-1].scrollIntoView({behavior:'smooth'})"
|
||||
:sx-on:sx:afterSwap "var items = document.querySelectorAll('#ref-evt-as-list > div'); if (items.length) items[items.length-1].scrollIntoView({behavior:'smooth'}); document.getElementById('ref-evt-as-count').textContent = items.length + ' items'"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||
"Add item (scrolls after settle)")
|
||||
(div :id "ref-evt-settle-list"
|
||||
"Add item (scrolls after swap)")
|
||||
(div :id "ref-evt-as-count"
|
||||
:class "text-sm text-emerald-700"
|
||||
"1 items")
|
||||
(div :id "ref-evt-as-list"
|
||||
:class "p-3 rounded border border-stone-200 space-y-1 max-h-32 overflow-y-auto"
|
||||
(div :class "text-sm text-stone-500" "Items will be appended and scrolled into view."))))
|
||||
|
||||
@@ -791,3 +815,102 @@
|
||||
(div :id "ref-evt-vf-result"
|
||||
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
|
||||
"Submit with empty/invalid email to trigger the event.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx:requestError event demo
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-event-request-error-demo ()
|
||||
(div :class "space-y-3"
|
||||
(button
|
||||
:sx-get "https://this-domain-does-not-exist.invalid/api"
|
||||
:sx-target "#ref-evt-re-result"
|
||||
:sx-swap "innerHTML"
|
||||
:sx-on:sx:requestError "document.getElementById('ref-evt-re-status').style.display = 'block'; document.getElementById('ref-evt-re-status').textContent = 'Network error — request never reached a server'"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||
"Request invalid domain")
|
||||
(div :id "ref-evt-re-status"
|
||||
:class "p-2 rounded bg-red-50 text-red-600 text-sm"
|
||||
:style "display: none"
|
||||
"")
|
||||
(div :id "ref-evt-re-result"
|
||||
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
|
||||
"Click to trigger a network error — sx:requestError fires.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx:clientRoute event demo
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-event-client-route-demo ()
|
||||
(div :class "space-y-3"
|
||||
(p :class "text-sm text-stone-600"
|
||||
"Open DevTools console, then navigate to a pure page (no :data expression). "
|
||||
"You'll see \"sx:route client /path\" in the console — no network request is made.")
|
||||
(div :class "flex gap-2 flex-wrap"
|
||||
(a :href "/essays/"
|
||||
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
|
||||
"Essays")
|
||||
(a :href "/plans/"
|
||||
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
|
||||
"Plans")
|
||||
(a :href "/protocols/"
|
||||
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
|
||||
"Protocols"))
|
||||
(p :class "text-xs text-stone-400"
|
||||
"The sx:clientRoute event fires on the swap target and bubbles to document.body. "
|
||||
"Apps use it to update nav selection, analytics, or other post-navigation state.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx:sseOpen event demo
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-event-sse-open-demo ()
|
||||
(div :class "space-y-3"
|
||||
(div :sx-sse "/reference/api/sse-time"
|
||||
:sx-sse-swap "time"
|
||||
:sx-swap "innerHTML"
|
||||
:sx-on:sx:sseOpen "document.getElementById('ref-evt-sseopen-status').textContent = 'Connected'; document.getElementById('ref-evt-sseopen-status').className = 'inline-block px-2 py-0.5 rounded text-xs bg-emerald-100 text-emerald-700'"
|
||||
(div :class "flex items-center gap-3"
|
||||
(span :id "ref-evt-sseopen-status"
|
||||
:class "inline-block px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700"
|
||||
"Connecting...")
|
||||
(span :class "text-sm text-stone-500" "SSE stream")))
|
||||
(p :class "text-xs text-stone-400"
|
||||
"The status badge turns green when the SSE connection opens.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx:sseMessage event demo
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-event-sse-message-demo ()
|
||||
(div :class "space-y-3"
|
||||
(div :sx-sse "/reference/api/sse-time"
|
||||
:sx-sse-swap "time"
|
||||
:sx-swap "innerHTML"
|
||||
:sx-on:sx:sseMessage "var c = parseInt(document.getElementById('ref-evt-ssemsg-count').dataset.count || '0') + 1; document.getElementById('ref-evt-ssemsg-count').dataset.count = c; document.getElementById('ref-evt-ssemsg-count').textContent = c + ' messages received'"
|
||||
(div :id "ref-evt-ssemsg-output"
|
||||
:class "p-3 rounded bg-stone-100 text-stone-600 text-sm font-mono"
|
||||
"Waiting for SSE messages..."))
|
||||
(div :id "ref-evt-ssemsg-count"
|
||||
:class "text-sm text-emerald-700"
|
||||
:data-count "0"
|
||||
"0 messages received")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx:sseError event demo
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-event-sse-error-demo ()
|
||||
(div :class "space-y-3"
|
||||
(div :sx-sse "/reference/api/sse-time"
|
||||
:sx-sse-swap "time"
|
||||
:sx-swap "innerHTML"
|
||||
:sx-on:sx:sseError "document.getElementById('ref-evt-sseerr-status').textContent = 'Disconnected'; document.getElementById('ref-evt-sseerr-status').className = 'inline-block px-2 py-0.5 rounded text-xs bg-red-100 text-red-700'"
|
||||
:sx-on:sx:sseOpen "document.getElementById('ref-evt-sseerr-status').textContent = 'Connected'; document.getElementById('ref-evt-sseerr-status').className = 'inline-block px-2 py-0.5 rounded text-xs bg-emerald-100 text-emerald-700'"
|
||||
(div :class "flex items-center gap-3"
|
||||
(span :id "ref-evt-sseerr-status"
|
||||
:class "inline-block px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700"
|
||||
"Connecting...")
|
||||
(span :class "text-sm text-stone-500" "SSE stream")))
|
||||
(p :class "text-xs text-stone-400"
|
||||
"If the SSE connection drops, the badge turns red via sx:sseError.")))
|
||||
|
||||
Reference in New Issue
Block a user