feat(api): streamline generated app authoring

Move the canonical examples toward generated component-root helpers, typed form decoding, async/state handler registration, and derive-driven app/component registry wiring. Tighten requirements and diagnostics for the server-first, selectorless authoring path.

Verified with cargo run -p slhx-xtask -- test, cargo check --workspace, redgate list, redgate refs, redgate health --strict, and git diff --check.

req: canonical/001

req: canonical/003

req: canonical/004

req: dx/002

req: derive_app/001

req: component/003

req: form/004

req: axum_integration/003
This commit is contained in:
slhx agent
2026-06-05 06:33:41 +02:00
parent eb6086616c
commit d4e865ef92
34 changed files with 4573 additions and 1156 deletions
+22 -13
View File
@@ -134,9 +134,10 @@
}
}
function formDataFor(el, eventName) {
function formDataFor(el, eventName, source = el) {
const form = formOwner(el);
const data = form ? new FormData(form) : new FormData();
if (form && source && source !== form && source.name && !source.disabled) data.append(source.name, source.value);
const id = handleId(el) || formHandleId(form);
if (id && !data.has("__h")) data.set("__h", id);
const dragKey = eventName === "drop" && dragKeys.get(rootOf(el));
@@ -168,9 +169,9 @@
return url.href;
}
async function send(el, eventName) {
async function send(el, eventName, source = el) {
if (el.getAttribute("data-slhx-confirm") && !confirm(el.getAttribute("data-slhx-confirm"))) return;
const { form, data, multipart } = formDataFor(el, eventName);
const { form, data, multipart } = formDataFor(el, eventName, source);
const target = form || el;
const policy = requestPolicy(target, eventName);
const active = pending.get(target);
@@ -182,7 +183,7 @@
if (active && policy === "queue") {
const base = queues.get(target) || active.done;
let queued;
const next = base.then(() => send(el, eventName));
const next = base.then(() => send(el, eventName, source));
queued = next.catch(() => {}).finally(() => {
if (queues.get(target) === queued) queues.delete(target);
});
@@ -309,11 +310,12 @@
}
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
putPayload(target, op.payload);
if (op.target.scope && op.target.scope.kind === "key" && op.payload.kind === "html") replacePayload(target, op.payload, op.target.scope.value, op.target.resource.id);
else putPayload(target, op.payload);
} else if (op.kind === "insert" || op.kind === "prepend") {
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
const nodes = fragmentNodes(op.payload, op.key);
const nodes = fragmentNodes(op.payload, op.key, op.target.resource.id);
target[op.kind === "prepend" ? "prepend" : "append"](...nodes);
} else if (op.kind === "remove") {
const target = op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target);
@@ -448,13 +450,20 @@
else target.textContent = payload.value;
}
function fragmentNodes(payload, key) {
function replacePayload(target, payload, key, resourceId) {
const nodes = fragmentNodes(payload, key, resourceId);
if (nodes.length) target.replaceWith(...nodes);
else target.innerHTML = "";
}
function fragmentNodes(payload, key, resourceId) {
const template = document.createElement("template");
if (payload.kind === "html") template.innerHTML = payload.value;
else template.textContent = payload.value;
const nodes = Array.from(template.content.childNodes);
const firstElement = nodes.find((node) => node.nodeType === 1);
if (firstElement && key != null && !firstElement.hasAttribute("data-key")) firstElement.setAttribute("data-key", key);
if (firstElement && resourceId != null && !firstElement.hasAttribute("data-sid")) firstElement.setAttribute("data-sid", resourceId);
return nodes;
}
@@ -589,7 +598,7 @@
if (form && root.contains(form) && formHandleId(form)) {
if (form.reportValidity && !form.reportValidity()) return;
event.preventDefault();
schedule(form, "submit");
schedule(form, "submit", submitter);
return;
}
}
@@ -600,23 +609,23 @@
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && formHandleId(event.target)) el = event.target;
if (!el || defaultEvent(el) !== name) return;
event.preventDefault();
schedule(el, name);
schedule(el, name, event.submitter || el);
});
});
bindPolling(root);
}
function schedule(el, eventName) {
function schedule(el, eventName, source = el) {
const debounce = duration(el.getAttribute("data-slhx-debounce"));
const throttle = duration(el.getAttribute("data-slhx-throttle"));
if (debounce) {
clearTimeout(timers.get(el));
timers.set(el, setTimeout(() => send(el, eventName), debounce));
timers.set(el, setTimeout(() => send(el, eventName, source), debounce));
} else if (throttle) {
if (timers.get(el)) return;
send(el, eventName).finally(() => setTimeout(() => timers.delete(el), throttle));
send(el, eventName, source).finally(() => setTimeout(() => timers.delete(el), throttle));
} else {
send(el, eventName);
send(el, eventName, source);
}
}