Add SSE, response headers, view transitions, and 5 new sx attributes

Implement missing SxEngine features:
- SSE (sx-sse, sx-sse-swap) with EventSource management and auto-cleanup
- Response headers: SX-Trigger, SX-Retarget, SX-Reswap, SX-Redirect,
  SX-Refresh, SX-Location, SX-Replace-Url, SX-Trigger-After-Swap/Settle
- View Transitions API: transition:true swap modifier + global config
- every:<time> trigger for polling (setInterval)
- sx-replace-url (replaceState instead of pushState)
- sx-disabled-elt (disable elements during request)
- sx-prompt (window.prompt, value sent as SX-Prompt header)
- sx-params (filter form parameters: *, none, not x,y, x,y)

Adds docs (ATTR_DETAILS, BEHAVIOR_ATTRS, headers, events), demo
components in reference.sx, API endpoints (prompt-echo, sse-time),
and 27 new unit tests for engine logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 11:55:21 +00:00
parent 3bffc212cc
commit 213421516e
6 changed files with 888 additions and 10 deletions

View File

@@ -882,4 +882,23 @@ def register(url_prefix: str = "/") -> Blueprint:
oob = _ref_wire("sx-retry", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/reference/api/prompt-echo")
async def ref_prompt_echo():
from shared.sx.helpers import sx_response
name = request.headers.get("SX-Prompt", "anonymous")
sx_src = f'(span :class "text-stone-800 text-sm" "Hello, " (strong "{name}") "!")'
oob = _ref_wire("sx-prompt", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/reference/api/sse-time")
async def ref_sse_time():
async def generate():
for _ in range(30): # stream for 60 seconds max
now = datetime.now().strftime("%H:%M:%S")
sx_src = f'(span :class "text-emerald-700 font-mono text-sm" "Server time: {now}")'
yield f"event: time\ndata: {sx_src}\n\n"
await asyncio.sleep(2)
return Response(generate(), content_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
return bp

View File

@@ -120,6 +120,12 @@ BEHAVIOR_ATTRS = [
("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),
("sx-replace-url", "Replace the current URL in the browser location bar (replaceState instead of pushState)", True),
("sx-disabled-elt", "CSS selector for elements to disable during the request", True),
("sx-prompt", "Show a prompt dialog before the request — input is sent as SX-Prompt header", True),
("sx-params", 'Filter which form parameters are sent: "*" (all), "none", "not x,y", or "x,y"', True),
("sx-sse", "Connect to a Server-Sent Events endpoint for real-time server push", True),
("sx-sse-swap", "SSE event name to listen for and swap into the target (default: message)", True),
]
SX_UNIQUE_ATTRS = [
@@ -140,11 +146,21 @@ REQUEST_HEADERS = [
("SX-Css", "hash or class list", "CSS classes/hash the client already has"),
("SX-History-Restore", "true", "Set when restoring from browser history"),
("SX-Css-Hash", "8-char hash", "Hash of the client's known CSS class set"),
("SX-Prompt", "string", "Value entered by the user in a window.prompt dialog (from sx-prompt)"),
]
RESPONSE_HEADERS = [
("SX-Css-Hash", "8-char hash", "Hash of the cumulative CSS class set after this response"),
("SX-Css-Add", "class1,class2,...", "New CSS classes added by this response"),
("SX-Trigger", "event or JSON", "Dispatch custom event(s) on the target element after the request"),
("SX-Trigger-After-Swap", "event or JSON", "Dispatch custom event(s) after the swap completes"),
("SX-Trigger-After-Settle", "event or JSON", "Dispatch custom event(s) after the DOM settles"),
("SX-Retarget", "CSS selector", "Override the target element for this response"),
("SX-Reswap", "swap strategy", "Override the swap strategy for this response"),
("SX-Redirect", "URL", "Redirect the browser to a new URL (full navigation)"),
("SX-Refresh", "true", "Reload the current page"),
("SX-Location", "URL or JSON", "Client-side navigation — fetch URL, swap into #main-panel, pushState"),
("SX-Replace-Url", "URL", "Replace the current URL using replaceState (server-side override)"),
]
# ---------------------------------------------------------------------------
@@ -158,6 +174,10 @@ EVENTS = [
("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:validationFailed", "Fired when sx-validate blocks a request due to invalid form data."),
("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."),
]
# ---------------------------------------------------------------------------
@@ -178,6 +198,7 @@ JS_API = [
("Sx.hydrate(root)", "Find and render all [data-sx] elements within root"),
("SxEngine.process(root)", "Process all sx attributes in the DOM subtree"),
("SxEngine.executeRequest(elt, verb, url)", "Programmatically trigger an sx request"),
("SxEngine.config.globalViewTransitions", "Enable View Transitions API globally for all swaps (default: false)"),
]
# ---------------------------------------------------------------------------
@@ -855,4 +876,110 @@ ATTR_DETAILS: dict[str, dict] = {
' "")'
),
},
# --- New attributes ---
"sx-replace-url": {
"description": (
"Replace the current URL in the browser location bar using replaceState "
"instead of pushState. The URL changes but no new history entry is created, "
"so the back button still goes to the previous page."
),
"demo": "ref-replace-url-demo",
"example": (
'(button :sx-get "/reference/api/time"\n'
' :sx-target "#ref-replurl-result"\n'
' :sx-swap "innerHTML"\n'
' :sx-replace-url "true"\n'
' "Load (replaces URL)")'
),
},
"sx-disabled-elt": {
"description": (
"CSS selector for elements to disable during the request. "
"The matched elements have their disabled property set to true when the "
"request starts, and restored to false when the request completes (success or error). "
"Useful for preventing double-submits on forms."
),
"demo": "ref-disabled-elt-demo",
"example": (
'(button :sx-get "/reference/api/slow-echo"\n'
' :sx-target "#ref-diselt-result"\n'
' :sx-swap "innerHTML"\n'
' :sx-disabled-elt "this"\n'
' :sx-vals "{\\"q\\": \\"hello\\"}"\n'
' "Click (disables during request)")'
),
},
"sx-prompt": {
"description": (
"Show a window.prompt dialog before the request. "
"If the user cancels, the request is not sent. "
"The entered value is sent as the SX-Prompt request header."
),
"demo": "ref-prompt-demo",
"example": (
'(button :sx-get "/reference/api/prompt-echo"\n'
' :sx-target "#ref-prompt-result"\n'
' :sx-swap "innerHTML"\n'
' :sx-prompt "Enter your name:"\n'
' "Prompt & send")'
),
"handler": (
'(defhandler ref-prompt-echo (&key)\n'
' (let ((name (or (header "SX-Prompt") "anonymous")))\n'
' (span "Hello, " (strong name) "!")))'
),
},
"sx-params": {
"description": (
"Filter which form parameters are sent with the request. "
'Values: "*" (all, default), "none" (no params), '
'"not x,y" (exclude named params), or "x,y" (include only named params).'
),
"demo": "ref-params-demo",
"example": (
'(form :sx-post "/reference/api/echo-vals"\n'
' :sx-target "#ref-params-result"\n'
' :sx-swap "innerHTML"\n'
' :sx-params "name"\n'
' (input :type "text" :name "name" :placeholder "Name (sent)")\n'
' (input :type "text" :name "secret" :placeholder "Secret (filtered)")\n'
' (button :type "submit" "Submit (only name)"))'
),
},
"sx-sse": {
"description": (
"Connect to a Server-Sent Events endpoint for real-time server push. "
"The value is the URL to connect to. Use sx-sse-swap to specify which "
"SSE event name to listen for. Incoming data is swapped into the target "
"using the standard sx-swap strategy. The EventSource is automatically "
"closed when the element is removed from the DOM."
),
"demo": "ref-sse-demo",
"example": (
'(div :sx-sse "/reference/api/sse-time"\n'
' :sx-sse-swap "time"\n'
' :sx-target "#ref-sse-result"\n'
' :sx-swap "innerHTML"\n'
' (div :id "ref-sse-result"\n'
' "Waiting for SSE updates..."))'
),
},
"sx-sse-swap": {
"description": (
"Specifies the SSE event name to listen for on the parent sx-sse connection. "
'Defaults to "message" if not specified. Multiple sx-sse-swap elements can '
"listen for different event types on the same connection."
),
"demo": "ref-sse-demo",
"example": (
'(div :sx-sse "/events/stream"\n'
' (div :sx-sse-swap "notifications"\n'
' :sx-target "#notif-area" :sx-swap "beforeend"\n'
' "Listening for notifications...")\n'
' (div :sx-sse-swap "status"\n'
' :sx-target "#status-bar" :sx-swap "innerHTML"\n'
' "Listening for status updates..."))'
),
},
}

View File

@@ -551,3 +551,95 @@
: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.")))
;; ---------------------------------------------------------------------------
;; sx-replace-url
;; ---------------------------------------------------------------------------
(defcomp ~ref-replace-url-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/time"
:sx-target "#ref-replurl-result"
:sx-swap "innerHTML"
:sx-replace-url "true"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Load (replaces URL)")
(div :id "ref-replurl-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Click to load — URL changes but no new history entry.")))
;; ---------------------------------------------------------------------------
;; sx-disabled-elt
;; ---------------------------------------------------------------------------
(defcomp ~ref-disabled-elt-demo ()
(div :class "space-y-3"
(div :class "flex gap-3 items-center"
(button :id "ref-diselt-btn"
:sx-get "/reference/api/slow-echo"
:sx-target "#ref-diselt-result"
:sx-swap "innerHTML"
:sx-disabled-elt "#ref-diselt-btn"
:sx-vals "{\"q\": \"hello\"}"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm disabled:opacity-50"
"Click (disables during request)")
(span :class "text-xs text-stone-400" "Button is disabled while request is in-flight."))
(div :id "ref-diselt-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Click the button to see it disable during the request.")))
;; ---------------------------------------------------------------------------
;; sx-prompt
;; ---------------------------------------------------------------------------
(defcomp ~ref-prompt-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/prompt-echo"
:sx-target "#ref-prompt-result"
:sx-swap "innerHTML"
:sx-prompt "Enter your name:"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Prompt & send")
(div :id "ref-prompt-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Click to enter a name via prompt — it is sent as the SX-Prompt header.")))
;; ---------------------------------------------------------------------------
;; sx-params
;; ---------------------------------------------------------------------------
(defcomp ~ref-params-demo ()
(div :class "space-y-3"
(form
:sx-post "/reference/api/echo-vals"
:sx-target "#ref-params-result"
:sx-swap "innerHTML"
:sx-params "name"
:class "flex gap-2"
(input :type "text" :name "name" :placeholder "Name (sent)"
:class "flex-1 px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
(input :type "text" :name "secret" :placeholder "Secret (filtered)"
: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-params-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Only 'name' will be sent — 'secret' is filtered by sx-params.")))
;; ---------------------------------------------------------------------------
;; sx-sse
;; ---------------------------------------------------------------------------
(defcomp ~ref-sse-demo ()
(div :class "space-y-3"
(div :sx-sse "/reference/api/sse-time"
:sx-sse-swap "time"
:sx-swap "innerHTML"
(div :id "ref-sse-result"
:class "p-3 rounded bg-stone-50 text-stone-600 text-sm font-mono"
"Connecting to SSE stream..."))
(p :class "text-xs text-stone-400"
"Server pushes time updates every 2 seconds via Server-Sent Events.")))