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:
@@ -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
|
||||
|
||||
@@ -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..."))'
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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.")))
|
||||
|
||||
Reference in New Issue
Block a user