diff --git a/sx/bp/pages/routes.py b/sx/bp/pages/routes.py index 86ea62a..5d7de4c 100644 --- a/sx/bp/pages/routes.py +++ b/sx/bp/pages/routes.py @@ -1,7 +1,11 @@ """SX docs page routes.""" from __future__ import annotations +import asyncio +import json +import random from datetime import datetime +from uuid import uuid4 from quart import Blueprint, Response, make_response, request @@ -240,6 +244,571 @@ def register(url_prefix: str = "/") -> Blueprint: oob_wire = _oob_code("oob-wire", wire_text) return sx_response(f'(<> {sx_src} {oob_wire})') + # --- Lazy Loading --- + + @bp.get("/examples/api/lazy") + async def api_lazy(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + now = datetime.now().strftime("%H:%M:%S") + sx_src = f'(~lazy-result :time "{now}")' + comp_text = _component_source_text("lazy-result") + wire_text = _full_wire_text(sx_src, "lazy-result") + oob_wire = _oob_code("lazy-wire", wire_text) + oob_comp = _oob_code("lazy-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- Infinite Scroll --- + + @bp.get("/examples/api/scroll") + async def api_scroll(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _full_wire_text + page = int(request.args.get("page", 2)) + start = (page - 1) * 5 + 1 + next_page = page + 1 + items_html = " ".join( + f'(div :class "px-4 py-3 border-b border-stone-100 text-sm text-stone-700" "Item {i} — loaded from page {page}")' + for i in range(start, start + 5) + ) + if next_page <= 6: + sentinel = ( + f'(div :id "scroll-sentinel"' + f' :sx-get "/examples/api/scroll?page={next_page}"' + f' :sx-trigger "intersect once"' + f' :sx-target "#scroll-items"' + f' :sx-swap "beforeend"' + f' :class "p-3 text-center text-stone-400 text-sm"' + f' "Loading more...")' + ) + else: + sentinel = ( + '(div :class "p-3 text-center text-stone-500 text-sm font-medium"' + ' "All items loaded.")' + ) + sx_src = f'(<> {items_html} {sentinel})' + wire_text = _full_wire_text(sx_src) + oob_wire = _oob_code("scroll-wire", wire_text) + return sx_response(f'(<> {sx_src} {oob_wire})') + + # --- Progress Bar --- + + _jobs: dict[str, int] = {} + + @bp.post("/examples/api/progress/start") + async def api_progress_start(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + job_id = str(uuid4())[:8] + _jobs[job_id] = 0 + sx_src = f'(~progress-status :percent 0 :job-id "{job_id}")' + comp_text = _component_source_text("progress-status") + wire_text = _full_wire_text(sx_src, "progress-status") + oob_wire = _oob_code("progress-wire", wire_text) + oob_comp = _oob_code("progress-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + @bp.get("/examples/api/progress/status") + async def api_progress_status(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + job_id = request.args.get("job", "") + current = _jobs.get(job_id, 0) + current = min(current + random.randint(15, 30), 100) + _jobs[job_id] = current + sx_src = f'(~progress-status :percent {current} :job-id "{job_id}")' + comp_text = _component_source_text("progress-status") + wire_text = _full_wire_text(sx_src, "progress-status") + oob_wire = _oob_code("progress-wire", wire_text) + oob_comp = _oob_code("progress-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- Active Search --- + + @bp.get("/examples/api/search") + async def api_search(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from content.pages import SEARCH_LANGUAGES + q = request.args.get("q", "").strip().lower() + if not q: + results = SEARCH_LANGUAGES + else: + results = [lang for lang in SEARCH_LANGUAGES if q in lang.lower()] + items_sx = " ".join(f'"{r}"' for r in results) + escaped_q = q.replace('"', '\\"') + sx_src = f'(~search-results :items (list {items_sx}) :query "{escaped_q}")' + comp_text = _component_source_text("search-results") + wire_text = _full_wire_text(sx_src, "search-results") + oob_wire = _oob_code("search-wire", wire_text) + oob_comp = _oob_code("search-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- Inline Validation --- + + _TAKEN_EMAILS = {"admin@example.com", "test@example.com", "user@example.com"} + + @bp.get("/examples/api/validate") + async def api_validate(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + email = request.args.get("email", "").strip() + if not email: + sx_src = '(~validation-error :message "Email is required")' + comp_name = "validation-error" + elif "@" not in email or "." not in email.split("@")[-1]: + sx_src = '(~validation-error :message "Invalid email format")' + comp_name = "validation-error" + elif email.lower() in _TAKEN_EMAILS: + escaped = email.replace('"', '\\"') + sx_src = f'(~validation-error :message "{escaped} is already taken")' + comp_name = "validation-error" + else: + escaped = email.replace('"', '\\"') + sx_src = f'(~validation-ok :email "{escaped}")' + comp_name = "validation-ok" + comp_text = _component_source_text(comp_name) + wire_text = _full_wire_text(sx_src, comp_name) + oob_wire = _oob_code("validate-wire", wire_text) + oob_comp = _oob_code("validate-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + @bp.post("/examples/api/validate/submit") + async def api_validate_submit(): + from shared.sx.helpers import sx_response + form = await request.form + email = form.get("email", "").strip() + if not email or "@" not in email: + return sx_response('(p :class "text-sm text-rose-600 mt-2" "Please enter a valid email.")') + escaped = email.replace('"', '\\"') + return sx_response(f'(p :class "text-sm text-emerald-600 mt-2" "Form submitted with: {escaped}")') + + # --- Value Select --- + + @bp.get("/examples/api/values") + async def api_values(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from content.pages import VALUE_SELECT_DATA + cat = request.args.get("category", "") + items = VALUE_SELECT_DATA.get(cat, []) + items_sx = " ".join(f'"{i}"' for i in items) + sx_src = f'(~value-options :items (list {items_sx}))' + comp_text = _component_source_text("value-options") + wire_text = _full_wire_text(sx_src, "value-options") + oob_wire = _oob_code("values-wire", wire_text) + oob_comp = _oob_code("values-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- Reset on Submit --- + + @bp.post("/examples/api/reset-submit") + async def api_reset_submit(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + form = await request.form + msg = form.get("message", "").strip() or "(empty)" + escaped = msg.replace('"', '\\"') + now = datetime.now().strftime("%H:%M:%S") + sx_src = f'(~reset-message :message "{escaped}" :time "{now}")' + comp_text = _component_source_text("reset-message") + wire_text = _full_wire_text(sx_src, "reset-message") + oob_wire = _oob_code("reset-wire", wire_text) + oob_comp = _oob_code("reset-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- Edit Row --- + + _edit_rows: dict[str, dict] = {} + + def _get_edit_rows() -> dict[str, dict]: + if not _edit_rows: + from content.pages import EDIT_ROW_DATA + for r in EDIT_ROW_DATA: + _edit_rows[r["id"]] = dict(r) + return _edit_rows + + @bp.get("/examples/api/editrow/") + async def api_editrow_form(row_id: str): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + rows = _get_edit_rows() + row = rows.get(row_id, {"id": row_id, "name": "", "price": "0", "stock": "0"}) + sx_src = (f'(~edit-row-form :id "{row["id"]}" :name "{row["name"]}"' + f' :price "{row["price"]}" :stock "{row["stock"]}")') + comp_text = _component_source_text("edit-row-form") + wire_text = _full_wire_text(sx_src, "edit-row-form") + oob_wire = _oob_code("editrow-wire", wire_text) + oob_comp = _oob_code("editrow-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + @bp.post("/examples/api/editrow/") + async def api_editrow_save(row_id: str): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + form = await request.form + rows = _get_edit_rows() + rows[row_id] = { + "id": row_id, + "name": form.get("name", ""), + "price": form.get("price", "0"), + "stock": form.get("stock", "0"), + } + row = rows[row_id] + sx_src = (f'(~edit-row-view :id "{row["id"]}" :name "{row["name"]}"' + f' :price "{row["price"]}" :stock "{row["stock"]}")') + comp_text = _component_source_text("edit-row-view") + wire_text = _full_wire_text(sx_src, "edit-row-view") + oob_wire = _oob_code("editrow-wire", wire_text) + oob_comp = _oob_code("editrow-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + @bp.get("/examples/api/editrow//cancel") + async def api_editrow_cancel(row_id: str): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + rows = _get_edit_rows() + row = rows.get(row_id, {"id": row_id, "name": "", "price": "0", "stock": "0"}) + sx_src = (f'(~edit-row-view :id "{row["id"]}" :name "{row["name"]}"' + f' :price "{row["price"]}" :stock "{row["stock"]}")') + comp_text = _component_source_text("edit-row-view") + wire_text = _full_wire_text(sx_src, "edit-row-view") + oob_wire = _oob_code("editrow-wire", wire_text) + oob_comp = _oob_code("editrow-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- Bulk Update --- + + _bulk_users: dict[str, dict] = {} + + def _get_bulk_users() -> dict[str, dict]: + if not _bulk_users: + from content.pages import BULK_USERS + for u in BULK_USERS: + _bulk_users[u["id"]] = dict(u) + return _bulk_users + + @bp.post("/examples/api/bulk") + async def api_bulk(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + action = request.args.get("action", "activate") + form = await request.form + ids = form.getlist("ids") + users = _get_bulk_users() + new_status = "active" if action == "activate" else "inactive" + for uid in ids: + if uid in users: + users[uid]["status"] = new_status + rows = [] + for u in users.values(): + rows.append( + f'(~bulk-row :id "{u["id"]}" :name "{u["name"]}"' + f' :email "{u["email"]}" :status "{u["status"]}")' + ) + sx_src = f'(<> {" ".join(rows)})' + comp_text = _component_source_text("bulk-row") + wire_text = _full_wire_text(sx_src, "bulk-row") + oob_wire = _oob_code("bulk-wire", wire_text) + oob_comp = _oob_code("bulk-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- Swap Positions --- + + _swap_count = {"n": 0} + + @bp.post("/examples/api/swap-log") + async def api_swap_log(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _full_wire_text + mode = request.args.get("mode", "beforeend") + _swap_count["n"] += 1 + now = datetime.now().strftime("%H:%M:%S") + n = _swap_count["n"] + entry = f'(div :class "px-3 py-2 text-sm text-stone-700" "[{now}] {mode} (#{n})")' + oob_counter = ( + f'(span :id "swap-counter" :sx-swap-oob "innerHTML"' + f' :class "self-center text-sm text-stone-500" "Count: {n}")' + ) + sx_src = f'(<> {entry} {oob_counter})' + wire_text = _full_wire_text(sx_src) + oob_wire = _oob_code("swap-wire", wire_text) + return sx_response(f'(<> {sx_src} {oob_wire})') + + # --- Select Filter (dashboard) --- + + @bp.get("/examples/api/dashboard") + async def api_dashboard(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _full_wire_text + now = datetime.now().strftime("%H:%M:%S") + sx_src = ( + f'(<>' + f' (div :id "dash-header" :class "p-3 bg-violet-50 rounded mb-3"' + f' (h4 :class "font-semibold text-violet-800" "Dashboard Header")' + f' (p :class "text-sm text-violet-600" "Generated at {now}"))' + f' (div :id "dash-stats" :class "grid grid-cols-3 gap-3 mb-3"' + f' (div :class "p-3 bg-emerald-50 rounded text-center"' + f' (p :class "text-2xl font-bold text-emerald-700" "142")' + f' (p :class "text-xs text-emerald-600" "Users"))' + f' (div :class "p-3 bg-blue-50 rounded text-center"' + f' (p :class "text-2xl font-bold text-blue-700" "89")' + f' (p :class "text-xs text-blue-600" "Orders"))' + f' (div :class "p-3 bg-amber-50 rounded text-center"' + f' (p :class "text-2xl font-bold text-amber-700" "$4.2k")' + f' (p :class "text-xs text-amber-600" "Revenue")))' + f' (div :id "dash-footer" :class "p-3 bg-stone-50 rounded"' + f' (p :class "text-sm text-stone-500" "Last updated: {now}")))' + ) + wire_text = _full_wire_text(sx_src) + oob_wire = _oob_code("filter-wire", wire_text) + return sx_response(f'(<> {sx_src} {oob_wire})') + + # --- Tabs --- + + _TAB_CONTENT = { + "tab1": ('(div (p :class "text-stone-700" "Welcome to the Overview tab.")' + ' (p :class "text-stone-500 text-sm mt-2"' + ' "This is the default tab content loaded via sx-get."))'), + "tab2": ('(div (p :class "text-stone-700" "Here are the details.")' + ' (ul :class "mt-2 space-y-1 text-sm text-stone-600"' + ' (li "Version: 1.0.0")' + ' (li "Build: 2024-01-15")' + ' (li "Engine: sx")))'), + "tab3": ('(div (p :class "text-stone-700" "Recent history:")' + ' (ol :class "mt-2 space-y-1 text-sm text-stone-600 list-decimal list-inside"' + ' (li "Initial release")' + ' (li "Added component caching")' + ' (li "Wire format v2")))'), + } + + @bp.get("/examples/api/tabs/") + async def api_tabs(tab: str): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _full_wire_text + sx_src = _TAB_CONTENT.get(tab, _TAB_CONTENT["tab1"]) + buttons = [] + for t, label in [("tab1", "Overview"), ("tab2", "Details"), ("tab3", "History")]: + active = "true" if t == tab else "false" + buttons.append(f'(~tab-btn :tab "{t}" :label "{label}" :active "{active}")') + oob_tabs = ( + f'(div :id "tab-buttons" :sx-swap-oob "innerHTML"' + f' :class "flex border-b border-stone-200"' + f' {" ".join(buttons)})' + ) + wire_text = _full_wire_text(f'(<> {sx_src} {oob_tabs})', "tab-btn") + oob_wire = _oob_code("tabs-wire", wire_text) + return sx_response(f'(<> {sx_src} {oob_tabs} {oob_wire})') + + # --- Animations --- + + @bp.get("/examples/api/animate") + async def api_animate(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + colors = ["bg-violet-100", "bg-emerald-100", "bg-blue-100", "bg-amber-100", "bg-rose-100"] + color = random.choice(colors) + now = datetime.now().strftime("%H:%M:%S") + sx_src = f'(~anim-result :color "{color}" :time "{now}")' + comp_text = _component_source_text("anim-result") + wire_text = _full_wire_text(sx_src, "anim-result") + oob_wire = _oob_code("anim-wire", wire_text) + oob_comp = _oob_code("anim-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- Dialogs --- + + @bp.get("/examples/api/dialog") + async def api_dialog(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + sx_src = '(~dialog-modal :title "Confirm Action" :message "Are you sure you want to proceed? This is a demo dialog rendered entirely with sx components.")' + comp_text = _component_source_text("dialog-modal") + wire_text = _full_wire_text(sx_src, "dialog-modal") + oob_wire = _oob_code("dialog-wire", wire_text) + oob_comp = _oob_code("dialog-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + @bp.get("/examples/api/dialog/close") + async def api_dialog_close(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _full_wire_text + wire_text = _full_wire_text("(empty — dialog closed)") + oob_wire = _oob_code("dialog-wire", wire_text) + return sx_response(f'(<> {oob_wire})') + + # --- Keyboard Shortcuts --- + + _KBD_ACTIONS = { + "s": "Search panel activated", + "n": "New item created", + "h": "Help panel opened", + } + + @bp.get("/examples/api/keyboard") + async def api_keyboard(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + key = request.args.get("key", "") + action = _KBD_ACTIONS.get(key, f"Unknown key: {key}") + escaped_action = action.replace('"', '\\"') + escaped_key = key.replace('"', '\\"') + sx_src = f'(~kbd-result :key "{escaped_key}" :action "{escaped_action}")' + comp_text = _component_source_text("kbd-result") + wire_text = _full_wire_text(sx_src, "kbd-result") + oob_wire = _oob_code("kbd-wire", wire_text) + oob_comp = _oob_code("kbd-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- PUT / PATCH --- + + _profile = {} + + def _get_profile() -> dict: + if not _profile: + from content.pages import PROFILE_DEFAULT + _profile.update(PROFILE_DEFAULT) + return _profile + + @bp.get("/examples/api/putpatch/edit-all") + async def api_pp_edit_all(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + p = _get_profile() + sx_src = f'(~pp-form-full :name "{p["name"]}" :email "{p["email"]}" :role "{p["role"]}")' + comp_text = _component_source_text("pp-form-full") + wire_text = _full_wire_text(sx_src, "pp-form-full") + oob_wire = _oob_code("pp-wire", wire_text) + oob_comp = _oob_code("pp-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + @bp.put("/examples/api/putpatch") + async def api_pp_put(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + form = await request.form + p = _get_profile() + p["name"] = form.get("name", p["name"]) + p["email"] = form.get("email", p["email"]) + p["role"] = form.get("role", p["role"]) + sx_src = f'(~pp-view :name "{p["name"]}" :email "{p["email"]}" :role "{p["role"]}")' + comp_text = _component_source_text("pp-view") + wire_text = _full_wire_text(sx_src, "pp-view") + oob_wire = _oob_code("pp-wire", wire_text) + oob_comp = _oob_code("pp-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + @bp.get("/examples/api/putpatch/cancel") + async def api_pp_cancel(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + p = _get_profile() + sx_src = f'(~pp-view :name "{p["name"]}" :email "{p["email"]}" :role "{p["role"]}")' + comp_text = _component_source_text("pp-view") + wire_text = _full_wire_text(sx_src, "pp-view") + oob_wire = _oob_code("pp-wire", wire_text) + oob_comp = _oob_code("pp-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- JSON Encoding --- + + @bp.post("/examples/api/json-echo") + async def api_json_echo(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + data = await request.get_json(silent=True) or {} + body = json.dumps(data, indent=2) + ct = request.content_type or "unknown" + escaped_body = body.replace('\\', '\\\\').replace('"', '\\"') + escaped_ct = ct.replace('"', '\\"') + sx_src = f'(~json-result :body "{escaped_body}" :content-type "{escaped_ct}")' + comp_text = _component_source_text("json-result") + wire_text = _full_wire_text(sx_src, "json-result") + oob_wire = _oob_code("json-wire", wire_text) + oob_comp = _oob_code("json-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- Vals & Headers --- + + @bp.get("/examples/api/echo-vals") + async def api_echo_vals(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + vals = {k: v for k, v in request.args.items() + if k not in ("_", "sx-request")} + items_sx = " ".join(f'"{k}: {v}"' for k, v in vals.items()) + sx_src = f'(~echo-result :label "values" :items (list {items_sx}))' + comp_text = _component_source_text("echo-result") + wire_text = _full_wire_text(sx_src, "echo-result") + oob_wire = _oob_code("vals-wire", wire_text) + oob_comp = _oob_code("vals-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + @bp.get("/examples/api/echo-headers") + async def api_echo_headers(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + custom = {k: v for k, v in request.headers if k.lower().startswith("x-")} + items_sx = " ".join(f'"{k}: {v}"' for k, v in custom.items()) + sx_src = f'(~echo-result :label "headers" :items (list {items_sx}))' + comp_text = _component_source_text("echo-result") + wire_text = _full_wire_text(sx_src, "echo-result") + oob_wire = _oob_code("vals-wire", wire_text) + oob_comp = _oob_code("vals-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- Loading States --- + + @bp.get("/examples/api/slow") + async def api_slow(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + await asyncio.sleep(2) + now = datetime.now().strftime("%H:%M:%S") + sx_src = f'(~loading-result :time "{now}")' + comp_text = _component_source_text("loading-result") + wire_text = _full_wire_text(sx_src, "loading-result") + oob_wire = _oob_code("loading-wire", wire_text) + oob_comp = _oob_code("loading-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- Request Abort (sync replace) --- + + @bp.get("/examples/api/slow-search") + async def api_slow_search(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + delay = random.uniform(0.5, 2.0) + await asyncio.sleep(delay) + q = request.args.get("q", "").strip() + delay_ms = int(delay * 1000) + escaped = q.replace('"', '\\"') + sx_src = f'(~sync-result :query "{escaped}" :delay "{delay_ms}")' + comp_text = _component_source_text("sync-result") + wire_text = _full_wire_text(sx_src, "sync-result") + oob_wire = _oob_code("sync-wire", wire_text) + oob_comp = _oob_code("sync-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + + # --- Retry --- + + _flaky = {"n": 0} + + @bp.get("/examples/api/flaky") + async def api_flaky(): + from shared.sx.helpers import sx_response + from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + _flaky["n"] += 1 + n = _flaky["n"] + if n % 3 != 0: + return Response("", status=503, content_type="text/plain") + sx_src = f'(~retry-result :attempt "{n}" :message "Success! The endpoint finally responded.")' + comp_text = _component_source_text("retry-result") + wire_text = _full_wire_text(sx_src, "retry-result") + oob_wire = _oob_code("retry-wire", wire_text) + oob_comp = _oob_code("retry-comp", comp_text) + return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + # ------------------------------------------------------------------ # Essays # ------------------------------------------------------------------ diff --git a/sx/content/pages.py b/sx/content/pages.py index 1577680..fccfdfb 100644 --- a/sx/content/pages.py +++ b/sx/content/pages.py @@ -42,6 +42,27 @@ EXAMPLES_NAV = [ ("Delete Row", "/examples/delete-row"), ("Inline Edit", "/examples/inline-edit"), ("OOB Swaps", "/examples/oob-swaps"), + ("Lazy Loading", "/examples/lazy-loading"), + ("Infinite Scroll", "/examples/infinite-scroll"), + ("Progress Bar", "/examples/progress-bar"), + ("Active Search", "/examples/active-search"), + ("Inline Validation", "/examples/inline-validation"), + ("Value Select", "/examples/value-select"), + ("Reset on Submit", "/examples/reset-on-submit"), + ("Edit Row", "/examples/edit-row"), + ("Bulk Update", "/examples/bulk-update"), + ("Swap Positions", "/examples/swap-positions"), + ("Select Filter", "/examples/select-filter"), + ("Tabs", "/examples/tabs"), + ("Animations", "/examples/animations"), + ("Dialogs", "/examples/dialogs"), + ("Keyboard Shortcuts", "/examples/keyboard-shortcuts"), + ("PUT / PATCH", "/examples/put-patch"), + ("JSON Encoding", "/examples/json-encoding"), + ("Vals & Headers", "/examples/vals-and-headers"), + ("Loading States", "/examples/loading-states"), + ("Request Abort", "/examples/sync-replace"), + ("Retry", "/examples/retry"), ] ESSAYS_NAV = [ @@ -182,3 +203,36 @@ DELETE_DEMO_ITEMS = [ ("4", "Deploy to production"), ("5", "Add unit tests"), ] + +# --------------------------------------------------------------------------- +# Static data for new examples +# --------------------------------------------------------------------------- + +SEARCH_LANGUAGES = [ + "Python", "JavaScript", "TypeScript", "Rust", "Go", "Java", "C", "C++", + "Ruby", "Elixir", "Haskell", "Clojure", "Scala", "Kotlin", "Swift", + "Zig", "OCaml", "Lua", "Perl", "PHP", +] + +PROFILE_DEFAULT = {"name": "Ada Lovelace", "email": "ada@example.com", "role": "Engineer"} + +BULK_USERS = [ + {"id": "1", "name": "Alice Chen", "email": "alice@example.com", "status": "active"}, + {"id": "2", "name": "Bob Rivera", "email": "bob@example.com", "status": "inactive"}, + {"id": "3", "name": "Carol Zhang", "email": "carol@example.com", "status": "active"}, + {"id": "4", "name": "Dan Okafor", "email": "dan@example.com", "status": "inactive"}, + {"id": "5", "name": "Eve Larsson", "email": "eve@example.com", "status": "active"}, +] + +VALUE_SELECT_DATA = { + "Languages": ["Python", "JavaScript", "Rust", "Go"], + "Frameworks": ["Quart", "FastAPI", "React", "Svelte"], + "Databases": ["PostgreSQL", "Redis", "SQLite", "MongoDB"], +} + +EDIT_ROW_DATA = [ + {"id": "1", "name": "Widget A", "price": "19.99", "stock": "142"}, + {"id": "2", "name": "Widget B", "price": "24.50", "stock": "89"}, + {"id": "3", "name": "Widget C", "price": "12.00", "stock": "305"}, + {"id": "4", "name": "Widget D", "price": "45.00", "stock": "67"}, +] diff --git a/sx/sxc/examples.sx b/sx/sxc/examples.sx index 56f2748..127ad5c 100644 --- a/sx/sxc/examples.sx +++ b/sx/sxc/examples.sx @@ -157,3 +157,623 @@ :sx-swap "innerHTML" :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" "Update both boxes"))) + +;; --- Lazy loading demo --- + +(defcomp ~lazy-loading-demo () + (div :class "space-y-4" + (p :class "text-sm text-stone-500" "The content below loads automatically when the page renders.") + (div :id "lazy-target" + :sx-get "/examples/api/lazy" + :sx-trigger "load" + :sx-swap "innerHTML" + :class "p-6 rounded border border-stone-200 bg-stone-50 text-center" + (div :class "animate-pulse space-y-2" + (div :class "h-4 bg-stone-200 rounded w-3/4 mx-auto") + (div :class "h-4 bg-stone-200 rounded w-1/2 mx-auto"))))) + +(defcomp ~lazy-result (&key time) + (div :class "space-y-2" + (p :class "text-stone-800 font-medium" "Content loaded on page render!") + (p :class "text-stone-500 text-sm" (str "Loaded via sx-trigger=\"load\" at " time)))) + +;; --- Infinite scroll demo --- + +(defcomp ~infinite-scroll-demo () + (div :class "h-64 overflow-y-auto border border-stone-200 rounded" :id "scroll-container" + (div :id "scroll-items" + (map-indexed (fn (i item) + (div :class "px-4 py-3 border-b border-stone-100 text-sm text-stone-700" + (str "Item " (+ i 1) " — loaded with the page"))) + (list 1 2 3 4 5)) + (div :id "scroll-sentinel" + :sx-get "/examples/api/scroll?page=2" + :sx-trigger "intersect once" + :sx-target "#scroll-items" + :sx-swap "beforeend" + :class "p-3 text-center text-stone-400 text-sm" + "Loading more...")))) + +(defcomp ~scroll-items (&key items page) + (<> + (map (fn (item) + (div :class "px-4 py-3 border-b border-stone-100 text-sm text-stone-700" item)) + items) + (when (<= page 5) + (div :id "scroll-sentinel" + :sx-get (str "/examples/api/scroll?page=" page) + :sx-trigger "intersect once" + :sx-target "#scroll-items" + :sx-swap "beforeend" + :class "p-3 text-center text-stone-400 text-sm" + "Loading more...")))) + +;; --- Progress bar demo --- + +(defcomp ~progress-bar-demo () + (div :class "space-y-4" + (div :id "progress-target" :class "space-y-3" + (div :class "w-full bg-stone-200 rounded-full h-4" + (div :class "bg-violet-600 h-4 rounded-full transition-all" :style "width: 0%")) + (p :class "text-sm text-stone-500 text-center" "Click start to begin.")) + (button + :sx-post "/examples/api/progress/start" + :sx-target "#progress-target" + :sx-swap "innerHTML" + :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" + "Start job"))) + +(defcomp ~progress-status (&key percent job-id) + (div :class "space-y-3" + (div :class "w-full bg-stone-200 rounded-full h-4" + (div :class "bg-violet-600 h-4 rounded-full transition-all" + :style (str "width: " percent "%"))) + (p :class "text-sm text-stone-500 text-center" (str percent "% complete")) + (when (< percent 100) + (div :sx-get (str "/examples/api/progress/status?job=" job-id) + :sx-trigger "load delay:500ms" + :sx-target "#progress-target" + :sx-swap "innerHTML")) + (when (= percent 100) + (p :class "text-sm text-emerald-600 font-medium text-center" "Job complete!")))) + +;; --- Active search demo --- + +(defcomp ~active-search-demo () + (div :class "space-y-3" + (input :type "text" :name "q" + :sx-get "/examples/api/search" + :sx-trigger "keyup delay:300ms changed" + :sx-target "#search-results" + :sx-swap "innerHTML" + :placeholder "Search programming languages..." + :class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500") + (div :id "search-results" :class "border border-stone-200 rounded divide-y divide-stone-100" + (p :class "p-3 text-sm text-stone-400" "Type to search...")))) + +(defcomp ~search-results (&key items query) + (<> + (if (empty? items) + (p :class "p-3 text-sm text-stone-400" (str "No results for \"" query "\"")) + (map (fn (item) + (div :class "px-3 py-2 text-sm text-stone-700" item)) + items)))) + +;; --- Inline validation demo --- + +(defcomp ~inline-validation-demo () + (form :class "space-y-4" :sx-post "/examples/api/validate/submit" :sx-target "#validation-result" :sx-swap "innerHTML" + (div + (label :class "block text-sm font-medium text-stone-700 mb-1" "Email") + (input :type "text" :name "email" :placeholder "user@example.com" + :sx-get "/examples/api/validate" + :sx-trigger "blur" + :sx-target "#email-feedback" + :sx-swap "innerHTML" + :class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500") + (div :id "email-feedback" :class "mt-1")) + (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 "validation-result"))) + +(defcomp ~validation-ok (&key email) + (p :class "text-sm text-emerald-600" (str email " is available"))) + +(defcomp ~validation-error (&key message) + (p :class "text-sm text-rose-600" message)) + +;; --- Value select demo --- + +(defcomp ~value-select-demo () + (div :class "space-y-3" + (div + (label :class "block text-sm font-medium text-stone-700 mb-1" "Category") + (select :name "category" + :sx-get "/examples/api/values" + :sx-trigger "change" + :sx-target "#value-items" + :sx-swap "innerHTML" + :class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500" + (option :value "" "Pick a category...") + (option :value "Languages" "Languages") + (option :value "Frameworks" "Frameworks") + (option :value "Databases" "Databases"))) + (div + (label :class "block text-sm font-medium text-stone-700 mb-1" "Item") + (select :id "value-items" + :class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500" + (option :value "" "Select a category first..."))))) + +(defcomp ~value-options (&key items) + (<> + (map (fn (item) (option :value item item)) items))) + +;; --- Reset on submit demo --- + +(defcomp ~reset-on-submit-demo () + (div :class "space-y-3" + (form :id "reset-form" + :sx-post "/examples/api/reset-submit" + :sx-target "#reset-result" + :sx-swap "innerHTML" + :sx-on:afterSwap "this.reset()" + :class "flex gap-2" + (input :type "text" :name "message" :placeholder "Type a message..." + :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" + "Send")) + (div :id "reset-result" :class "space-y-2" + (p :class "text-sm text-stone-400" "Messages will appear here.")))) + +(defcomp ~reset-message (&key message time) + (div :class "px-3 py-2 bg-stone-50 rounded text-sm text-stone-700" + (str "[" time "] " message))) + +;; --- Edit row demo --- + +(defcomp ~edit-row-demo (&key rows) + (div + (table :class "w-full text-left text-sm" + (thead + (tr :class "border-b border-stone-200" + (th :class "px-3 py-2 font-medium text-stone-600" "Name") + (th :class "px-3 py-2 font-medium text-stone-600" "Price") + (th :class "px-3 py-2 font-medium text-stone-600" "Stock") + (th :class "px-3 py-2 font-medium text-stone-600 w-24" ""))) + (tbody :id "edit-rows" + (map (fn (row) + (~edit-row-view :id (nth 0 row) :name (nth 1 row) :price (nth 2 row) :stock (nth 3 row))) + rows))))) + +(defcomp ~edit-row-view (&key id name price stock) + (tr :id (str "erow-" id) :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" name) + (td :class "px-3 py-2 text-stone-700" (str "$" price)) + (td :class "px-3 py-2 text-stone-700" stock) + (td :class "px-3 py-2" + (button + :sx-get (str "/examples/api/editrow/" id) + :sx-target (str "#erow-" id) + :sx-swap "outerHTML" + :class "text-sm text-violet-600 hover:text-violet-800" + "edit")))) + +(defcomp ~edit-row-form (&key id name price stock) + (tr :id (str "erow-" id) :class "border-b border-stone-100 bg-violet-50" + (td :class "px-3 py-2" + (input :type "text" :name "name" :value name + :class "w-full px-2 py-1 border border-stone-300 rounded text-sm")) + (td :class "px-3 py-2" + (input :type "text" :name "price" :value price + :class "w-20 px-2 py-1 border border-stone-300 rounded text-sm")) + (td :class "px-3 py-2" + (input :type "text" :name "stock" :value stock + :class "w-20 px-2 py-1 border border-stone-300 rounded text-sm")) + (td :class "px-3 py-2 space-x-1" + (button + :sx-post (str "/examples/api/editrow/" id) + :sx-target (str "#erow-" id) + :sx-swap "outerHTML" + :sx-include (str "#erow-" id) + :class "text-sm text-emerald-600 hover:text-emerald-800" + "save") + (button + :sx-get (str "/examples/api/editrow/" id "/cancel") + :sx-target (str "#erow-" id) + :sx-swap "outerHTML" + :class "text-sm text-stone-500 hover:text-stone-700" + "cancel")))) + +;; --- Bulk update demo --- + +(defcomp ~bulk-update-demo (&key users) + (div :class "space-y-3" + (form :id "bulk-form" + (div :class "flex gap-2 mb-3" + (button :type "button" + :sx-post "/examples/api/bulk?action=activate" + :sx-target "#bulk-table" + :sx-swap "innerHTML" + :sx-include "#bulk-form" + :class "px-3 py-1.5 bg-emerald-600 text-white rounded text-sm hover:bg-emerald-700" + "Activate") + (button :type "button" + :sx-post "/examples/api/bulk?action=deactivate" + :sx-target "#bulk-table" + :sx-swap "innerHTML" + :sx-include "#bulk-form" + :class "px-3 py-1.5 bg-stone-600 text-white rounded text-sm hover:bg-stone-700" + "Deactivate")) + (table :class "w-full text-left text-sm" + (thead + (tr :class "border-b border-stone-200" + (th :class "px-3 py-2 w-8" "") + (th :class "px-3 py-2 font-medium text-stone-600" "Name") + (th :class "px-3 py-2 font-medium text-stone-600" "Email") + (th :class "px-3 py-2 font-medium text-stone-600" "Status"))) + (tbody :id "bulk-table" + (map (fn (u) + (~bulk-row :id (nth 0 u) :name (nth 1 u) :email (nth 2 u) :status (nth 3 u))) + users)))))) + +(defcomp ~bulk-row (&key id name email status) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2" + (input :type "checkbox" :name "ids" :value id)) + (td :class "px-3 py-2 text-stone-700" name) + (td :class "px-3 py-2 text-stone-700" email) + (td :class "px-3 py-2" + (span :class (str "px-2 py-0.5 rounded text-xs font-medium " + (if (= status "active") + "bg-emerald-100 text-emerald-700" + "bg-stone-100 text-stone-500")) + status)))) + +;; --- Swap positions demo --- + +(defcomp ~swap-positions-demo () + (div :class "space-y-3" + (div :class "flex gap-2" + (button + :sx-post "/examples/api/swap-log?mode=beforeend" + :sx-target "#swap-log" + :sx-swap "beforeend" + :class "px-3 py-1.5 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" + "Add to End") + (button + :sx-post "/examples/api/swap-log?mode=afterbegin" + :sx-target "#swap-log" + :sx-swap "afterbegin" + :class "px-3 py-1.5 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" + "Add to Start") + (button + :sx-post "/examples/api/swap-log?mode=none" + :sx-target "#swap-log" + :sx-swap "none" + :class "px-3 py-1.5 bg-stone-600 text-white rounded text-sm hover:bg-stone-700" + "Silent Ping") + (span :id "swap-counter" :class "self-center text-sm text-stone-500" "Count: 0")) + (div :id "swap-log" + :class "border border-stone-200 rounded h-48 overflow-y-auto divide-y divide-stone-100" + (p :class "p-3 text-sm text-stone-400" "Log entries will appear here.")))) + +(defcomp ~swap-entry (&key time mode) + (div :class "px-3 py-2 text-sm text-stone-700" + (str "[" time "] " mode))) + +;; --- Select filter demo --- + +(defcomp ~select-filter-demo () + (div :class "space-y-3" + (div :class "flex gap-2" + (button + :sx-get "/examples/api/dashboard" + :sx-target "#filter-target" + :sx-swap "innerHTML" + :sx-select "#dash-stats" + :class "px-3 py-1.5 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" + "Stats Only") + (button + :sx-get "/examples/api/dashboard" + :sx-target "#filter-target" + :sx-swap "innerHTML" + :sx-select "#dash-header" + :class "px-3 py-1.5 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" + "Header Only") + (button + :sx-get "/examples/api/dashboard" + :sx-target "#filter-target" + :sx-swap "innerHTML" + :class "px-3 py-1.5 bg-stone-600 text-white rounded text-sm hover:bg-stone-700" + "Full Dashboard")) + (div :id "filter-target" :class "border border-stone-200 rounded p-4 bg-white" + (p :class "text-sm text-stone-400" "Click a button to load content.")))) + +;; --- Tabs demo --- + +(defcomp ~tabs-demo () + (div :class "space-y-0" + (div :class "flex border-b border-stone-200" :id "tab-buttons" + (~tab-btn :tab "tab1" :label "Overview" :active "true") + (~tab-btn :tab "tab2" :label "Details" :active "false") + (~tab-btn :tab "tab3" :label "History" :active "false")) + (div :id "tab-content" :class "p-4 border border-t-0 border-stone-200 rounded-b" + (p :class "text-stone-700" "Welcome to the Overview tab. This content is loaded by default.") + (p :class "text-stone-500 text-sm mt-2" "Click the tabs above to navigate. Watch the browser URL update.")))) + +(defcomp ~tab-btn (&key tab label active) + (button + :sx-get (str "/examples/api/tabs/" tab) + :sx-target "#tab-content" + :sx-swap "innerHTML" + :sx-push-url (str "/examples/tabs?tab=" tab) + :class (str "px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors " + (if (= active "true") + "border-violet-600 text-violet-600" + "border-transparent text-stone-500 hover:text-stone-700")) + label)) + +;; --- Animations demo --- + +(defcomp ~animations-demo () + (div :class "space-y-4" + (button + :sx-get "/examples/api/animate" + :sx-target "#anim-target" + :sx-swap "innerHTML" + :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" + "Load with animation") + (div :id "anim-target" :class "p-4 rounded border border-stone-200 bg-white text-center" + (p :class "text-stone-400" "Content will fade in here.")))) + +(defcomp ~anim-result (&key color time) + (div :class "sx-fade-in space-y-2" + (style ".sx-fade-in { animation: sxFadeIn 0.5s ease-out; } @keyframes sxFadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }") + (div :class (str "p-4 rounded transition-colors duration-700 " color) + (p :class "font-medium" "Faded in!") + (p :class "text-sm mt-1" (str "Loaded at " time))))) + +;; --- Dialogs demo --- + +(defcomp ~dialogs-demo () + (div + (button + :sx-get "/examples/api/dialog" + :sx-target "#dialog-container" + :sx-swap "innerHTML" + :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" + "Open Dialog") + (div :id "dialog-container"))) + +(defcomp ~dialog-modal (&key title message) + (div :class "fixed inset-0 z-50 flex items-center justify-center" + (div :class "absolute inset-0 bg-black/50" + :sx-get "/examples/api/dialog/close" + :sx-target "#dialog-container" + :sx-swap "innerHTML") + (div :class "relative bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 space-y-4" + (h3 :class "text-lg font-semibold text-stone-800" title) + (p :class "text-stone-600" message) + (div :class "flex justify-end gap-2" + (button + :sx-get "/examples/api/dialog/close" + :sx-target "#dialog-container" + :sx-swap "innerHTML" + :class "px-4 py-2 bg-stone-200 text-stone-700 rounded text-sm hover:bg-stone-300" + "Cancel") + (button + :sx-get "/examples/api/dialog/close" + :sx-target "#dialog-container" + :sx-swap "innerHTML" + :class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" + "Confirm"))))) + +;; --- Keyboard shortcuts demo --- + +(defcomp ~keyboard-shortcuts-demo () + (div :class "space-y-4" + (div :class "p-4 rounded border border-stone-200 bg-stone-50" + (p :class "text-sm text-stone-600 font-medium mb-2" "Keyboard shortcuts:") + (div :class "flex gap-4" + (div :class "flex items-center gap-1" + (kbd :class "px-2 py-0.5 bg-white border border-stone-300 rounded text-xs font-mono" "s") + (span :class "text-sm text-stone-500" "Search")) + (div :class "flex items-center gap-1" + (kbd :class "px-2 py-0.5 bg-white border border-stone-300 rounded text-xs font-mono" "n") + (span :class "text-sm text-stone-500" "New item")) + (div :class "flex items-center gap-1" + (kbd :class "px-2 py-0.5 bg-white border border-stone-300 rounded text-xs font-mono" "h") + (span :class "text-sm text-stone-500" "Help")))) + (div :id "kbd-target" + :sx-get "/examples/api/keyboard?key=s" + :sx-trigger "keyup[key=='s'&&!event.target.matches('input,textarea')] from:body" + :sx-swap "innerHTML" + :class "p-4 rounded border border-stone-200 bg-white text-center" + (p :class "text-stone-400 text-sm" "Press a shortcut key...")) + (div :sx-get "/examples/api/keyboard?key=n" + :sx-trigger "keyup[key=='n'&&!event.target.matches('input,textarea')] from:body" + :sx-target "#kbd-target" + :sx-swap "innerHTML") + (div :sx-get "/examples/api/keyboard?key=h" + :sx-trigger "keyup[key=='h'&&!event.target.matches('input,textarea')] from:body" + :sx-target "#kbd-target" + :sx-swap "innerHTML"))) + +(defcomp ~kbd-result (&key key action) + (div :class "space-y-1" + (p :class "text-stone-800 font-medium" action) + (p :class "text-sm text-stone-500" (str "Triggered by pressing '" key "'")))) + +;; --- PUT / PATCH demo --- + +(defcomp ~put-patch-demo (&key name email role) + (div :id "pp-target" :class "space-y-4" + (~pp-view :name name :email email :role role))) + +(defcomp ~pp-view (&key name email role) + (div :class "space-y-3" + (div :class "flex justify-between items-start" + (div + (p :class "text-stone-800 font-medium" name) + (p :class "text-sm text-stone-500" email) + (p :class "text-sm text-stone-500" role)) + (button + :sx-get "/examples/api/putpatch/edit-all" + :sx-target "#pp-target" + :sx-swap "innerHTML" + :class "text-sm text-violet-600 hover:text-violet-800" + "Edit All (PUT)")))) + +(defcomp ~pp-form-full (&key name email role) + (form + :sx-put "/examples/api/putpatch" + :sx-target "#pp-target" + :sx-swap "innerHTML" + :class "space-y-3" + (div + (label :class "block text-sm font-medium text-stone-700 mb-1" "Name") + (input :type "text" :name "name" :value name + :class "w-full px-3 py-2 border border-stone-300 rounded text-sm")) + (div + (label :class "block text-sm font-medium text-stone-700 mb-1" "Email") + (input :type "text" :name "email" :value email + :class "w-full px-3 py-2 border border-stone-300 rounded text-sm")) + (div + (label :class "block text-sm font-medium text-stone-700 mb-1" "Role") + (input :type "text" :name "role" :value role + :class "w-full px-3 py-2 border border-stone-300 rounded text-sm")) + (div :class "flex gap-2" + (button :type "submit" + :class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700" + "Save All (PUT)") + (button :type "button" + :sx-get "/examples/api/putpatch/cancel" + :sx-target "#pp-target" + :sx-swap "innerHTML" + :class "px-4 py-2 bg-stone-200 text-stone-700 rounded text-sm hover:bg-stone-300" + "Cancel")))) + +;; --- JSON encoding demo --- + +(defcomp ~json-encoding-demo () + (div :class "space-y-4" + (form + :sx-post "/examples/api/json-echo" + :sx-target "#json-result" + :sx-swap "innerHTML" + :sx-encoding "json" + :class "space-y-3" + (div + (label :class "block text-sm font-medium text-stone-700 mb-1" "Name") + (input :type "text" :name "name" :value "Ada Lovelace" + :class "w-full px-3 py-2 border border-stone-300 rounded text-sm")) + (div + (label :class "block text-sm font-medium text-stone-700 mb-1" "Age") + (input :type "number" :name "age" :value "36" + :class "w-full px-3 py-2 border border-stone-300 rounded text-sm")) + (button :type "submit" + :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" + "Submit as JSON")) + (div :id "json-result" :class "p-3 rounded bg-stone-50 text-stone-500 text-sm" + "Submit the form to see the server echo the parsed JSON."))) + +(defcomp ~json-result (&key body content-type) + (div :class "space-y-2" + (p :class "text-stone-800 font-medium" "Server received:") + (pre :class "text-sm bg-stone-100 p-3 rounded overflow-x-auto" (code body)) + (p :class "text-sm text-stone-500" (str "Content-Type: " content-type)))) + +;; --- Vals & Headers demo --- + +(defcomp ~vals-headers-demo () + (div :class "space-y-6" + (div :class "space-y-2" + (h4 :class "text-sm font-semibold text-stone-700" "sx-vals — send extra values") + (button + :sx-get "/examples/api/echo-vals" + :sx-target "#vals-result" + :sx-swap "innerHTML" + :sx-vals "{\"source\": \"button\", \"version\": \"2.0\"}" + :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" + "Send with vals") + (div :id "vals-result" :class "p-3 rounded bg-stone-50 text-sm text-stone-400" + "Click to see server-received values.")) + (div :class "space-y-2" + (h4 :class "text-sm font-semibold text-stone-700" "sx-headers — send custom headers") + (button + :sx-get "/examples/api/echo-headers" + :sx-target "#headers-result" + :sx-swap "innerHTML" + :sx-headers "{\"X-Custom-Token\": \"abc123\", \"X-Request-Source\": \"demo\"}" + :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" + "Send with headers") + (div :id "headers-result" :class "p-3 rounded bg-stone-50 text-sm text-stone-400" + "Click to see server-received headers.")))) + +(defcomp ~echo-result (&key label items) + (div :class "space-y-1" + (p :class "text-stone-800 font-medium" (str "Server received " label ":")) + (map (fn (item) + (div :class "text-sm text-stone-600 font-mono" item)) + items))) + +;; --- Loading states demo --- + +(defcomp ~loading-states-demo () + (div :class "space-y-4" + (style ".sx-loading-btn.sx-request { opacity: 0.7; pointer-events: none; } .sx-loading-btn.sx-request .sx-spinner { display: inline-block; } .sx-loading-btn .sx-spinner { display: none; }") + (button + :sx-get "/examples/api/slow" + :sx-target "#loading-result" + :sx-swap "innerHTML" + :class "sx-loading-btn px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm flex items-center gap-2" + (span :class "sx-spinner w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin") + (span "Load slow endpoint")) + (div :id "loading-result" :class "p-4 rounded border border-stone-200 bg-white text-center" + (p :class "text-stone-400 text-sm" "Click the button — it takes 2 seconds.")))) + +(defcomp ~loading-result (&key time) + (div + (p :class "text-stone-800 font-medium" "Loaded!") + (p :class "text-sm text-stone-500" (str "Response arrived at " time)))) + +;; --- Sync replace demo (request abort) --- + +(defcomp ~sync-replace-demo () + (div :class "space-y-3" + (input :type "text" :name "q" + :sx-get "/examples/api/slow-search" + :sx-trigger "keyup delay:200ms changed" + :sx-target "#sync-result" + :sx-swap "innerHTML" + :sx-sync "replace" + :placeholder "Type to search (random delay 0.5-2s)..." + :class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500") + (div :id "sync-result" :class "p-4 rounded border border-stone-200 bg-white" + (p :class "text-sm text-stone-400" "Type to trigger requests — stale ones get aborted.")))) + +(defcomp ~sync-result (&key query delay) + (div + (p :class "text-stone-800 font-medium" (str "Result for: \"" query "\"")) + (p :class "text-sm text-stone-500" (str "Server took " delay "ms to respond")))) + +;; --- Retry demo --- + +(defcomp ~retry-demo () + (div :class "space-y-4" + (button + :sx-get "/examples/api/flaky" + :sx-target "#retry-result" + :sx-swap "innerHTML" + :sx-retry "exponential:1000:8000" + :class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm" + "Call flaky endpoint") + (div :id "retry-result" :class "p-4 rounded border border-stone-200 bg-white text-center" + (p :class "text-stone-400 text-sm" "Endpoint fails twice, succeeds on 3rd attempt.")))) + +(defcomp ~retry-result (&key attempt message) + (div :class "space-y-1" + (p :class "text-stone-800 font-medium" message) + (p :class "text-sm text-stone-500" (str "Succeeded on attempt #" attempt)))) diff --git a/sx/sxc/sx_components.py b/sx/sxc/sx_components.py index f660b6b..2ccb522 100644 --- a/sx/sxc/sx_components.py +++ b/sx/sxc/sx_components.py @@ -901,6 +901,27 @@ def _examples_content_sx(slug: str) -> str: "delete-row": _example_delete_row_sx, "inline-edit": _example_inline_edit_sx, "oob-swaps": _example_oob_swaps_sx, + "lazy-loading": _example_lazy_loading_sx, + "infinite-scroll": _example_infinite_scroll_sx, + "progress-bar": _example_progress_bar_sx, + "active-search": _example_active_search_sx, + "inline-validation": _example_inline_validation_sx, + "value-select": _example_value_select_sx, + "reset-on-submit": _example_reset_on_submit_sx, + "edit-row": _example_edit_row_sx, + "bulk-update": _example_bulk_update_sx, + "swap-positions": _example_swap_positions_sx, + "select-filter": _example_select_filter_sx, + "tabs": _example_tabs_sx, + "animations": _example_animations_sx, + "dialogs": _example_dialogs_sx, + "keyboard-shortcuts": _example_keyboard_shortcuts_sx, + "put-patch": _example_put_patch_sx, + "json-encoding": _example_json_encoding_sx, + "vals-and-headers": _example_vals_and_headers_sx, + "loading-states": _example_loading_states_sx, + "sync-replace": _example_sync_replace_sx, + "retry": _example_retry_sx, } return builders.get(slug, _example_click_to_load_sx)() @@ -1121,6 +1142,802 @@ def _example_oob_swaps_sx() -> str: ) +def _example_lazy_loading_sx() -> str: + c_sx = _example_code('(div\n' + ' :sx-get "/examples/api/lazy"\n' + ' :sx-trigger "load"\n' + ' :sx-swap "innerHTML"\n' + ' (div :class "animate-pulse" "Loading..."))') + c_handler = _example_code('@bp.get("/examples/api/lazy")\n' + 'async def api_lazy():\n' + ' now = datetime.now().strftime(...)\n' + ' return sx_response(\n' + ' f\'(~lazy-result :time "{now}")\')', + language="python") + return ( + f'(~doc-page :title "Lazy Loading"' + f' (p :class "text-stone-600 mb-6"' + f' "Use sx-trigger=\\"load\\" to fetch content as soon as the element enters the DOM. ' + f'Great for deferring expensive content below the fold.")' + f' (~example-card :title "Demo"' + f' :description "Content loads automatically when the page renders."' + f' (~example-demo (~lazy-loading-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("lazy-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("lazy-wire")})' + ) + + +def _example_infinite_scroll_sx() -> str: + c_sx = _example_code('(div :id "scroll-sentinel"\n' + ' :sx-get "/examples/api/scroll?page=2"\n' + ' :sx-trigger "intersect once"\n' + ' :sx-target "#scroll-items"\n' + ' :sx-swap "beforeend"\n' + ' "Loading more...")') + c_handler = _example_code('@bp.get("/examples/api/scroll")\n' + 'async def api_scroll():\n' + ' page = int(request.args.get("page", 2))\n' + ' items = [f"Item {i}" for i in range(...)]\n' + ' # Include next sentinel if more pages\n' + ' return sx_response(items_sx + sentinel_sx)', + language="python") + return ( + f'(~doc-page :title "Infinite Scroll"' + f' (p :class "text-stone-600 mb-6"' + f' "A sentinel element at the bottom uses sx-trigger=\\"intersect once\\" ' + f'to load the next page when scrolled into view. Each response appends items and a new sentinel.")' + f' (~example-card :title "Demo"' + f' :description "Scroll down in the container to load more items (5 pages total)."' + f' (~example-demo (~infinite-scroll-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("scroll-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("scroll-wire")})' + ) + + +def _example_progress_bar_sx() -> str: + c_sx = _example_code(';; Start the job\n' + '(button\n' + ' :sx-post "/examples/api/progress/start"\n' + ' :sx-target "#progress-target"\n' + ' :sx-swap "innerHTML")\n\n' + ';; Each response re-polls via sx-trigger="load"\n' + '(div :sx-get "/api/progress/status?job=ID"\n' + ' :sx-trigger "load delay:500ms"\n' + ' :sx-target "#progress-target"\n' + ' :sx-swap "innerHTML")') + c_handler = _example_code('@bp.post("/examples/api/progress/start")\n' + 'async def api_progress_start():\n' + ' job_id = str(uuid4())[:8]\n' + ' _jobs[job_id] = 0\n' + ' return sx_response(\n' + ' f\'(~progress-status :percent 0 :job-id "{job_id}")\')', + language="python") + return ( + f'(~doc-page :title "Progress Bar"' + f' (p :class "text-stone-600 mb-6"' + f' "Start a server-side job, then poll for progress using sx-trigger=\\"load delay:500ms\\" on each response. ' + f'The bar fills up and stops when complete.")' + f' (~example-card :title "Demo"' + f' :description "Click start to begin a simulated job."' + f' (~example-demo (~progress-bar-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("progress-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("progress-wire")})' + ) + + +def _example_active_search_sx() -> str: + c_sx = _example_code('(input :type "text" :name "q"\n' + ' :sx-get "/examples/api/search"\n' + ' :sx-trigger "keyup delay:300ms changed"\n' + ' :sx-target "#search-results"\n' + ' :sx-swap "innerHTML"\n' + ' :placeholder "Search...")') + c_handler = _example_code('@bp.get("/examples/api/search")\n' + 'async def api_search():\n' + ' q = request.args.get("q", "").lower()\n' + ' results = [l for l in LANGUAGES if q in l.lower()]\n' + ' return sx_response(\n' + ' f\'(~search-results :items (...) :query "{q}")\')', + language="python") + return ( + f'(~doc-page :title "Active Search"' + f' (p :class "text-stone-600 mb-6"' + f' "An input with sx-trigger=\\"keyup delay:300ms changed\\" debounces keystrokes and only fires when the value changes. ' + f'The server filters a list of programming languages.")' + f' (~example-card :title "Demo"' + f' :description "Type to search through 20 programming languages."' + f' (~example-demo (~active-search-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("search-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("search-wire")})' + ) + + +def _example_inline_validation_sx() -> str: + c_sx = _example_code('(input :type "text" :name "email"\n' + ' :sx-get "/examples/api/validate"\n' + ' :sx-trigger "blur"\n' + ' :sx-target "#email-feedback"\n' + ' :sx-swap "innerHTML"\n' + ' :placeholder "user@example.com")') + c_handler = _example_code('@bp.get("/examples/api/validate")\n' + 'async def api_validate():\n' + ' email = request.args.get("email", "")\n' + ' if "@" not in email:\n' + ' return sx_response(\'(~validation-error ...)\')\n' + ' return sx_response(\'(~validation-ok ...)\')', + language="python") + return ( + f'(~doc-page :title "Inline Validation"' + f' (p :class "text-stone-600 mb-6"' + f' "Validate an email field on blur. The server checks format and whether it is taken, ' + f'returning green or red feedback inline.")' + f' (~example-card :title "Demo"' + f' :description "Enter an email and click away (blur) to validate."' + f' (~example-demo (~inline-validation-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("validate-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("validate-wire")})' + ) + + +def _example_value_select_sx() -> str: + c_sx = _example_code('(select :name "category"\n' + ' :sx-get "/examples/api/values"\n' + ' :sx-trigger "change"\n' + ' :sx-target "#value-items"\n' + ' :sx-swap "innerHTML"\n' + ' (option "Languages")\n' + ' (option "Frameworks")\n' + ' (option "Databases"))') + c_handler = _example_code('@bp.get("/examples/api/values")\n' + 'async def api_values():\n' + ' cat = request.args.get("category", "")\n' + ' items = VALUE_SELECT_DATA.get(cat, [])\n' + ' return sx_response(\n' + ' f\'(~value-options :items (list ...))\')', + language="python") + return ( + f'(~doc-page :title "Value Select"' + f' (p :class "text-stone-600 mb-6"' + f' "Two linked selects: pick a category and the second select updates with matching items via sx-get.")' + f' (~example-card :title "Demo"' + f' :description "Select a category to populate the item dropdown."' + f' (~example-demo (~value-select-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("values-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("values-wire")})' + ) + + +def _example_reset_on_submit_sx() -> str: + c_sx = _example_code('(form :id "reset-form"\n' + ' :sx-post "/examples/api/reset-submit"\n' + ' :sx-target "#reset-result"\n' + ' :sx-swap "innerHTML"\n' + ' :sx-on:afterSwap "this.reset()"\n' + ' (input :type "text" :name "message")\n' + ' (button :type "submit" "Send"))') + c_handler = _example_code('@bp.post("/examples/api/reset-submit")\n' + 'async def api_reset_submit():\n' + ' form = await request.form\n' + ' msg = form.get("message", "")\n' + ' return sx_response(\n' + ' f\'(~reset-message :message "{msg}" :time "...")\')', + language="python") + return ( + f'(~doc-page :title "Reset on Submit"' + f' (p :class "text-stone-600 mb-6"' + f' "Use sx-on:afterSwap=\\"this.reset()\\" to clear form inputs after a successful submission.")' + f' (~example-card :title "Demo"' + f' :description "Submit a message — the input resets after each send."' + f' (~example-demo (~reset-on-submit-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("reset-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("reset-wire")})' + ) + + +def _example_edit_row_sx() -> str: + from content.pages import EDIT_ROW_DATA + rows_sx = " ".join( + f'(list "{r["id"]}" "{r["name"]}" "{r["price"]}" "{r["stock"]}")' + for r in EDIT_ROW_DATA + ) + c_sx = _example_code('(button\n' + ' :sx-get "/examples/api/editrow/1"\n' + ' :sx-target "#erow-1"\n' + ' :sx-swap "outerHTML"\n' + ' "edit")\n\n' + ';; Save sends form data via POST\n' + '(button\n' + ' :sx-post "/examples/api/editrow/1"\n' + ' :sx-target "#erow-1"\n' + ' :sx-swap "outerHTML"\n' + ' :sx-include "#erow-1"\n' + ' "save")') + c_handler = _example_code('@bp.get("/examples/api/editrow/")\n' + 'async def api_editrow_form(id):\n' + ' row = EDIT_ROW_DATA[id]\n' + ' return sx_response(\n' + ' f\'(~edit-row-form :id ... :name ...)\')\n\n' + '@bp.post("/examples/api/editrow/")\n' + 'async def api_editrow_save(id):\n' + ' form = await request.form\n' + ' return sx_response(\n' + ' f\'(~edit-row-view :id ... :name ...)\')', + language="python") + return ( + f'(~doc-page :title "Edit Row"' + f' (p :class "text-stone-600 mb-6"' + f' "Click edit to replace a table row with input fields. Save or cancel swaps back the display row. ' + f'Uses sx-include to gather form values from the row.")' + f' (~example-card :title "Demo"' + f' :description "Click edit on any row to modify it inline."' + f' (~example-demo (~edit-row-demo :rows (list {rows_sx}))))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("editrow-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("editrow-wire")})' + ) + + +def _example_bulk_update_sx() -> str: + from content.pages import BULK_USERS + users_sx = " ".join( + f'(list "{u["id"]}" "{u["name"]}" "{u["email"]}" "{u["status"]}")' + for u in BULK_USERS + ) + c_sx = _example_code('(button\n' + ' :sx-post "/examples/api/bulk?action=activate"\n' + ' :sx-target "#bulk-table"\n' + ' :sx-swap "innerHTML"\n' + ' :sx-include "#bulk-form"\n' + ' "Activate")') + c_handler = _example_code('@bp.post("/examples/api/bulk")\n' + 'async def api_bulk():\n' + ' action = request.args.get("action")\n' + ' form = await request.form\n' + ' ids = form.getlist("ids")\n' + ' # Update matching users\n' + ' return sx_response(updated_rows)', + language="python") + return ( + f'(~doc-page :title "Bulk Update"' + f' (p :class "text-stone-600 mb-6"' + f' "Select rows with checkboxes and use Activate/Deactivate buttons. ' + f'sx-include gathers checkbox values from the form.")' + f' (~example-card :title "Demo"' + f' :description "Check some rows, then click Activate or Deactivate."' + f' (~example-demo (~bulk-update-demo :users (list {users_sx}))))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("bulk-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("bulk-wire")})' + ) + + +def _example_swap_positions_sx() -> str: + c_sx = _example_code(';; Append to end\n' + '(button :sx-post "/api/swap-log?mode=beforeend"\n' + ' :sx-target "#swap-log" :sx-swap "beforeend"\n' + ' "Add to End")\n\n' + ';; Prepend to start\n' + '(button :sx-post "/api/swap-log?mode=afterbegin"\n' + ' :sx-target "#swap-log" :sx-swap "afterbegin"\n' + ' "Add to Start")\n\n' + ';; No swap — OOB counter update only\n' + '(button :sx-post "/api/swap-log?mode=none"\n' + ' :sx-target "#swap-log" :sx-swap "none"\n' + ' "Silent Ping")') + c_handler = _example_code('@bp.post("/examples/api/swap-log")\n' + 'async def api_swap_log():\n' + ' mode = request.args.get("mode")\n' + ' # OOB counter updates on every request\n' + ' oob = f\'(span :id "swap-counter" :sx-swap-oob "innerHTML" "Count: {n}")\'\n' + ' return sx_response(entry + oob)', + language="python") + return ( + f'(~doc-page :title "Swap Positions"' + f' (p :class "text-stone-600 mb-6"' + f' "Demonstrates different swap modes: beforeend appends, afterbegin prepends, ' + f'and none skips the main swap while still processing OOB updates.")' + f' (~example-card :title "Demo"' + f' :description "Try each button to see different swap behaviours."' + f' (~example-demo (~swap-positions-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("swap-wire")})' + ) + + +def _example_select_filter_sx() -> str: + c_sx = _example_code(';; Pick just the stats section from the response\n' + '(button\n' + ' :sx-get "/examples/api/dashboard"\n' + ' :sx-target "#filter-target"\n' + ' :sx-swap "innerHTML"\n' + ' :sx-select "#dash-stats"\n' + ' "Stats Only")\n\n' + ';; No sx-select — get the full response\n' + '(button\n' + ' :sx-get "/examples/api/dashboard"\n' + ' :sx-target "#filter-target"\n' + ' :sx-swap "innerHTML"\n' + ' "Full Dashboard")') + c_handler = _example_code('@bp.get("/examples/api/dashboard")\n' + 'async def api_dashboard():\n' + ' # Returns header + stats + footer\n' + ' # Client uses sx-select to pick sections\n' + ' return sx_response(\n' + ' \'(<> (div :id "dash-header" ...) \'\n' + ' \' (div :id "dash-stats" ...) \'\n' + ' \' (div :id "dash-footer" ...))\')', + language="python") + return ( + f'(~doc-page :title "Select Filter"' + f' (p :class "text-stone-600 mb-6"' + f' "sx-select lets the client pick a specific section from the server response by CSS selector. ' + f'The server always returns the full dashboard — the client filters.")' + f' (~example-card :title "Demo"' + f' :description "Different buttons select different parts of the same server response."' + f' (~example-demo (~select-filter-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("filter-wire")})' + ) + + +def _example_tabs_sx() -> str: + c_sx = _example_code('(button\n' + ' :sx-get "/examples/api/tabs/tab1"\n' + ' :sx-target "#tab-content"\n' + ' :sx-swap "innerHTML"\n' + ' :sx-push-url "/examples/tabs?tab=tab1"\n' + ' "Overview")') + c_handler = _example_code('@bp.get("/examples/api/tabs/")\n' + 'async def api_tabs(tab: str):\n' + ' content = TAB_CONTENT[tab]\n' + ' return sx_response(content)', + language="python") + return ( + f'(~doc-page :title "Tabs"' + f' (p :class "text-stone-600 mb-6"' + f' "Tab navigation using sx-push-url to update the browser URL. ' + f'Back/forward buttons navigate between previously visited tabs.")' + f' (~example-card :title "Demo"' + f' :description "Click tabs to switch content. Watch the browser URL change."' + f' (~example-demo (~tabs-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("tabs-wire")})' + ) + + +def _example_animations_sx() -> str: + c_sx = _example_code('(button\n' + ' :sx-get "/examples/api/animate"\n' + ' :sx-target "#anim-target"\n' + ' :sx-swap "innerHTML"\n' + ' "Load with animation")\n\n' + ';; Component uses CSS animation class\n' + '(defcomp ~anim-result (&key color time)\n' + ' (div :class "sx-fade-in ..."\n' + ' (style ".sx-fade-in { animation: sxFadeIn 0.5s }")\n' + ' (p "Faded in!")))') + c_handler = _example_code('@bp.get("/examples/api/animate")\n' + 'async def api_animate():\n' + ' colors = ["bg-violet-100", "bg-emerald-100", ...]\n' + ' color = random.choice(colors)\n' + ' return sx_response(\n' + ' f\'(~anim-result :color "{color}" :time "{now}")\')', + language="python") + return ( + f'(~doc-page :title "Animations"' + f' (p :class "text-stone-600 mb-6"' + f' "CSS animations play on swap. The component injects a style tag with a keyframe animation ' + f'and applies the class. Each click picks a random background colour.")' + f' (~example-card :title "Demo"' + f' :description "Click to swap in content with a fade-in animation."' + f' (~example-demo (~animations-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("anim-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("anim-wire")})' + ) + + +def _example_dialogs_sx() -> str: + c_sx = _example_code('(button\n' + ' :sx-get "/examples/api/dialog"\n' + ' :sx-target "#dialog-container"\n' + ' :sx-swap "innerHTML"\n' + ' "Open Dialog")\n\n' + ';; Dialog closes by swapping empty content\n' + '(button\n' + ' :sx-get "/examples/api/dialog/close"\n' + ' :sx-target "#dialog-container"\n' + ' :sx-swap "innerHTML"\n' + ' "Close")') + c_handler = _example_code('@bp.get("/examples/api/dialog")\n' + 'async def api_dialog():\n' + ' return sx_response(\n' + ' \'(~dialog-modal :title "Confirm"\'\n' + ' \' :message "Are you sure?")\')\n\n' + '@bp.get("/examples/api/dialog/close")\n' + 'async def api_dialog_close():\n' + ' return sx_response("")', + language="python") + return ( + f'(~doc-page :title "Dialogs"' + f' (p :class "text-stone-600 mb-6"' + f' "Open a modal dialog by swapping in the dialog component. Close by swapping in empty content. ' + f'Pure sx — no JavaScript library needed.")' + f' (~example-card :title "Demo"' + f' :description "Click to open a modal dialog."' + f' (~example-demo (~dialogs-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("dialog-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("dialog-wire")})' + ) + + +def _example_keyboard_shortcuts_sx() -> str: + c_sx = _example_code('(div :id "kbd-target"\n' + ' :sx-get "/examples/api/keyboard?key=s"\n' + ' :sx-trigger "keyup[key==\'s\'&&!event.target.matches(\'input,textarea\')] from:body"\n' + ' :sx-swap "innerHTML"\n' + ' "Press a shortcut key...")') + c_handler = _example_code('@bp.get("/examples/api/keyboard")\n' + 'async def api_keyboard():\n' + ' key = request.args.get("key", "")\n' + ' actions = {"s": "Search", "n": "New item", "h": "Help"}\n' + ' return sx_response(\n' + ' f\'(~kbd-result :key "{key}" :action "{actions[key]}")\')', + language="python") + return ( + f'(~doc-page :title "Keyboard Shortcuts"' + f' (p :class "text-stone-600 mb-6"' + f' "Use sx-trigger with keyup event filters and from:body to listen for global keyboard shortcuts. ' + f'The filter prevents firing when typing in inputs.")' + f' (~example-card :title "Demo"' + f' :description "Press s, n, or h on your keyboard."' + f' (~example-demo (~keyboard-shortcuts-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("kbd-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("kbd-wire")})' + ) + + +def _example_put_patch_sx() -> str: + from content.pages import PROFILE_DEFAULT + c_sx = _example_code(';; Replace entire resource\n' + '(form :sx-put "/examples/api/putpatch"\n' + ' :sx-target "#pp-target" :sx-swap "innerHTML"\n' + ' (input :name "name") (input :name "email")\n' + ' (button "Save All (PUT)"))') + c_handler = _example_code('@bp.put("/examples/api/putpatch")\n' + 'async def api_put():\n' + ' form = await request.form\n' + ' # Full replacement\n' + ' return sx_response(\'(~pp-view ...)\')', + language="python") + n, e, r = PROFILE_DEFAULT["name"], PROFILE_DEFAULT["email"], PROFILE_DEFAULT["role"] + return ( + f'(~doc-page :title "PUT / PATCH"' + f' (p :class "text-stone-600 mb-6"' + f' "sx-put replaces the entire resource. This example shows a profile card with an Edit All button ' + f'that sends a PUT with all fields.")' + f' (~example-card :title "Demo"' + f' :description "Click Edit All to replace the full profile via PUT."' + f' (~example-demo (~put-patch-demo :name "{n}" :email "{e}" :role "{r}")))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("pp-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("pp-wire")})' + ) + + +def _example_json_encoding_sx() -> str: + c_sx = _example_code('(form\n' + ' :sx-post "/examples/api/json-echo"\n' + ' :sx-target "#json-result"\n' + ' :sx-swap "innerHTML"\n' + ' :sx-encoding "json"\n' + ' (input :name "name" :value "Ada")\n' + ' (input :type "number" :name "age" :value "36")\n' + ' (button "Submit as JSON"))') + c_handler = _example_code('@bp.post("/examples/api/json-echo")\n' + 'async def api_json_echo():\n' + ' data = await request.get_json()\n' + ' body = json.dumps(data, indent=2)\n' + ' ct = request.content_type\n' + ' return sx_response(\n' + ' f\'(~json-result :body "{body}" :content-type "{ct}")\')', + language="python") + return ( + f'(~doc-page :title "JSON Encoding"' + f' (p :class "text-stone-600 mb-6"' + f' "Use sx-encoding=\\"json\\" to send form data as a JSON body instead of URL-encoded form data. ' + f'The server echoes back what it received.")' + f' (~example-card :title "Demo"' + f' :description "Submit the form and see the JSON body the server received."' + f' (~example-demo (~json-encoding-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("json-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("json-wire")})' + ) + + +def _example_vals_and_headers_sx() -> str: + c_sx = _example_code(';; Send extra values with the request\n' + '(button\n' + ' :sx-get "/examples/api/echo-vals"\n' + ' :sx-vals "{\\\\\"source\\\\\": \\\\\"button\\\\\"}"\n' + ' "Send with vals")\n\n' + ';; Send custom headers\n' + '(button\n' + ' :sx-get "/examples/api/echo-headers"\n' + ' :sx-headers "{\\\\\"X-Custom-Token\\\\\": \\\\\"abc123\\\\\"}"\n' + ' "Send with headers")') + c_handler = _example_code('@bp.get("/examples/api/echo-vals")\n' + 'async def api_echo_vals():\n' + ' vals = dict(request.args)\n' + ' return sx_response(\n' + ' f\'(~echo-result :label "values" :items (...))\')\n\n' + '@bp.get("/examples/api/echo-headers")\n' + 'async def api_echo_headers():\n' + ' custom = {k: v for k, v in request.headers\n' + ' if k.startswith("X-")}\n' + ' return sx_response(\n' + ' f\'(~echo-result :label "headers" :items (...))\')', + language="python") + return ( + f'(~doc-page :title "Vals & Headers"' + f' (p :class "text-stone-600 mb-6"' + f' "sx-vals adds extra key/value pairs to the request parameters. ' + f'sx-headers adds custom HTTP headers. The server echoes back what it received.")' + f' (~example-card :title "Demo"' + f' :description "Click each button to see what the server receives."' + f' (~example-demo (~vals-headers-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("vals-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("vals-wire")})' + ) + + +def _example_loading_states_sx() -> str: + c_sx = _example_code(';; .sx-request class added during request\n' + '(style ".sx-loading-btn.sx-request {\n' + ' opacity: 0.7; pointer-events: none; }\n' + '.sx-loading-btn.sx-request .sx-spinner {\n' + ' display: inline-block; }\n' + '.sx-loading-btn .sx-spinner {\n' + ' display: none; }")\n\n' + '(button :class "sx-loading-btn"\n' + ' :sx-get "/examples/api/slow"\n' + ' :sx-target "#loading-result"\n' + ' (span :class "sx-spinner animate-spin" "...")\n' + ' "Load slow endpoint")') + c_handler = _example_code('@bp.get("/examples/api/slow")\n' + 'async def api_slow():\n' + ' await asyncio.sleep(2)\n' + ' return sx_response(\n' + ' f\'(~loading-result :time "{now}")\')', + language="python") + return ( + f'(~doc-page :title "Loading States"' + f' (p :class "text-stone-600 mb-6"' + f' "sx.js adds the .sx-request CSS class to any element that has an active request. ' + f'Use pure CSS to show spinners, disable buttons, or change opacity during loading.")' + f' (~example-card :title "Demo"' + f' :description "Click the button — it shows a spinner during the 2-second request."' + f' (~example-demo (~loading-states-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("loading-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("loading-wire")})' + ) + + +def _example_sync_replace_sx() -> str: + c_sx = _example_code('(input :type "text" :name "q"\n' + ' :sx-get "/examples/api/slow-search"\n' + ' :sx-trigger "keyup delay:200ms changed"\n' + ' :sx-target "#sync-result"\n' + ' :sx-swap "innerHTML"\n' + ' :sx-sync "replace"\n' + ' "Type to search...")') + c_handler = _example_code('@bp.get("/examples/api/slow-search")\n' + 'async def api_slow_search():\n' + ' delay = random.uniform(0.5, 2.0)\n' + ' await asyncio.sleep(delay)\n' + ' q = request.args.get("q", "")\n' + ' return sx_response(\n' + ' f\'(~sync-result :query "{q}" :delay "{delay_ms}")\')', + language="python") + return ( + f'(~doc-page :title "Request Abort"' + f' (p :class "text-stone-600 mb-6"' + f' "sx-sync=\\"replace\\" aborts any in-flight request before sending a new one. ' + f'This prevents stale responses from overwriting newer ones, even with random server delays.")' + f' (~example-card :title "Demo"' + f' :description "Type quickly — only the latest result appears despite random 0.5-2s server delays."' + f' (~example-demo (~sync-replace-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("sync-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("sync-wire")})' + ) + + +def _example_retry_sx() -> str: + c_sx = _example_code('(button\n' + ' :sx-get "/examples/api/flaky"\n' + ' :sx-target "#retry-result"\n' + ' :sx-swap "innerHTML"\n' + ' :sx-retry "exponential:1000:8000"\n' + ' "Call flaky endpoint")') + c_handler = _example_code('@bp.get("/examples/api/flaky")\n' + 'async def api_flaky():\n' + ' _flaky["n"] += 1\n' + ' if _flaky["n"] % 3 != 0:\n' + ' return Response("", status=503)\n' + ' return sx_response(\n' + ' f\'(~retry-result :attempt {n} ...)\')', + language="python") + return ( + f'(~doc-page :title "Retry"' + f' (p :class "text-stone-600 mb-6"' + f' "sx-retry=\\"exponential:1000:8000\\" retries failed requests with exponential backoff ' + f'starting at 1s up to 8s. The endpoint fails the first 2 attempts and succeeds on the 3rd.")' + f' (~example-card :title "Demo"' + f' :description "Click the button — watch it retry automatically after failures."' + f' (~example-demo (~retry-demo)))' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")' + f' {c_sx}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Component")' + f' {_placeholder("retry-comp")}' + f' (h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")' + f' {c_handler}' + f' (div :class "flex items-center justify-between mt-6"' + f' (h3 :class "text-lg font-semibold text-stone-700" "Wire response")' + f' {_clear_components_btn()})' + f' {_placeholder("retry-wire")})' + ) + + # --------------------------------------------------------------------------- # Essays # ---------------------------------------------------------------------------