diff --git a/slhx-js/runtime/slhx.js b/slhx-js/runtime/slhx.js index 4a45c01..eb4129a 100644 --- a/slhx-js/runtime/slhx.js +++ b/slhx-js/runtime/slhx.js @@ -13,28 +13,34 @@ const dragKeys = new WeakMap(); function roots() { - return Array.from(document.querySelectorAll(`[${ROOT}]`)); + const found = []; + forEachElement(document, (el) => { if (el.hasAttribute(ROOT)) found.push(el); }); + return found; } function rootOf(node) { - return node && node.closest ? node.closest(`[${ROOT}]`) : null; + for (let el = node; el && el !== document; el = el.parentElement) { + if (el.hasAttribute && el.hasAttribute(ROOT)) return el; + } + return null; } function formOwner(el) { if (!el || el.tagName === "FORM") return el; if (el.getAttribute && el.getAttribute("form")) return document.getElementById(el.getAttribute("form")); - return el.closest ? el.closest("form") : null; + return closestInRoot(el, rootOf(el) || document, (node) => node.tagName === "FORM"); } function formHandleId(form) { if (!form) return null; - const raw = form.getAttribute(HID) || (form.querySelector(`[${HID}]`) && form.querySelector(`[${HID}]`).getAttribute(HID)); + const holder = form.hasAttribute(HID) ? form : firstElement(form, (el) => el.hasAttribute(HID)); + const raw = holder && holder.getAttribute(HID); return raw && /^\d+$/.test(raw) ? raw : null; } - function closestInRoot(start, root, selector) { - for (let node = start; node && node !== root.parentNode; node = node.parentNode) { - if (node.matches && node.matches(selector)) return node; + function closestInRoot(start, root, predicate) { + for (let node = start; node && node !== root.parentNode; node = node.parentElement) { + if (predicate(node)) return node; if (node === root) break; } return null; @@ -61,9 +67,11 @@ const klass = el.getAttribute("data-slhx-pending-class"); if (klass) el.classList.toggle(klass, on); const root = rootOf(el) || document; - root.querySelectorAll("[data-slhx-indicator]").forEach((i) => { i.hidden = !on; }); + forEachElement(root, (i) => { if (i.hasAttribute("data-slhx-indicator")) i.hidden = !on; }); if (el.hasAttribute("data-slhx-disable-while-pending")) { - const controls = el.matches("button,input,select,textarea") ? [el] : el.querySelectorAll("button,input,select,textarea"); + const controls = []; + if (isDisableControl(el)) controls.push(el); + forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); }); controls.forEach((c) => { c.disabled = on; }); } } @@ -279,7 +287,59 @@ if (isAtom(ref)) return null; if (ref.scope && ref.scope.kind === "key") return keyedTarget(scope, ref.resource.id, ref.scope.value); if (ref.scope && ref.scope.kind === "field") return fieldTarget(scope, ref.resource.id, ref.scope.value); - return scope.querySelector(`[data-sid="${ref.resource.id}"], [data-slot-id="${ref.resource.id}"]`); + return generatedTarget(scope, ref.resource.id); + } + + function firstElement(scope, predicate) { + const stack = []; + for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) stack.push(node); + while (stack.length) { + const node = stack.shift(); + if (predicate(node)) return node; + for (let child = node.firstElementChild; child; child = child.nextElementSibling) stack.push(child); + } + return null; + } + + function attrEquals(el, name, value) { + return el && el.getAttribute && el.getAttribute(name) === String(value); + } + + function generatedResource(el, id) { + return attrEquals(el, "data-sid", id) || attrEquals(el, "data-slot-id", id); + } + + function generatedForm(el, id) { + return attrEquals(el, "data-fid", id) || attrEquals(el, "data-form-id", id); + } + + function generatedTarget(scope, id) { + if (scope && generatedResource(scope, id)) return scope; + return firstElement(scope, (el) => generatedResource(el, id)); + } + + function withinGeneratedResource(el, scope, id) { + for (let node = el; node && node !== scope.parentNode; node = node.parentElement) { + if (generatedResource(node, id)) return true; + if (node === scope) break; + } + return false; + } + + function withinGeneratedForm(el, scope, id) { + for (let node = el; node && node !== scope.parentNode; node = node.parentElement) { + if (generatedForm(node, id)) return true; + if (node === scope) break; + } + return false; + } + + function isInputControl(el) { + return el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT"); + } + + function isDisableControl(el) { + return isInputControl(el) || (el && el.tagName === "BUTTON"); } function isAtom(ref) { @@ -301,29 +361,26 @@ } function keyedTarget(scope, id, key) { - const escapedKey = cssEscape(key); - return scope.querySelector(`[data-sid="${id}"][data-key="${escapedKey}"], [data-sid="${id}"] [data-key="${escapedKey}"], [data-slot-id="${id}"][data-key="${escapedKey}"], [data-slot-id="${id}"] [data-key="${escapedKey}"]`); + return firstElement(scope, (el) => attrEquals(el, "data-key", key) && withinGeneratedResource(el, scope, id)); } function fieldTarget(scope, id, field) { - const escapedField = cssEscape(field); - return scope.querySelector(`[data-fid="${id}"] [name="${escapedField}"], [data-form-id="${id}"] [name="${escapedField}"]`); + return firstElement(scope, (el) => attrEquals(el, "name", field) && withinGeneratedForm(el, scope, id)); } function formErrorTarget(scope, id, field) { - const escapedField = cssEscape(field); - return scope.querySelector(`[data-fid="${id}"] [data-slhx-error-for="${escapedField}"], [data-form-id="${id}"] [data-slhx-error-for="${escapedField}"]`) || - scope.querySelector(`[data-fid="${id}"] [name="${escapedField}"], [data-form-id="${id}"] [name="${escapedField}"]`); + return firstElement(scope, (el) => attrEquals(el, "data-slhx-error-for", field) && withinGeneratedForm(el, scope, id)) || + firstElement(scope, (el) => attrEquals(el, "name", field) && withinGeneratedForm(el, scope, id)); } function putFormError(target, message) { - if (target.matches && target.matches("input,textarea,select")) target.setCustomValidity(message); + if (isInputControl(target)) target.setCustomValidity(message); else target.textContent = message; } function putPayload(target, payload) { if (payload.kind === "html") target.innerHTML = payload.value; - else if (target.matches && target.matches("input,textarea,select")) target.value = payload.value; + else if (isInputControl(target)) target.value = payload.value; else target.textContent = payload.value; } @@ -339,14 +396,14 @@ function handleRuntimeEvent(scope, name, payload) { if (name === "slhx:form-reset") { - const form = scope.querySelector(`[data-fid="${payload}"], [data-form-id="${payload}"]`); + const form = generatedFormTarget(scope, payload); if (form && form.reset) form.reset(); } else if (name === "slhx:form-error") { const [id, field, message] = String(payload).split("\u001f"); const target = formErrorTarget(scope, id, field); if (target) putFormError(target, message || ""); } else if (name === "slhx:form-disable-while-pending") { - const form = scope.querySelector(`[data-fid="${payload}"], [data-form-id="${payload}"]`); + const form = generatedFormTarget(scope, payload); if (form) form.setAttribute("data-slhx-disable-while-pending", ""); } } @@ -363,7 +420,7 @@ function applyHtml(html, root, title) { const scope = root || document; const doc = new DOMParser().parseFromString(html, "text/html"); - const template = doc.querySelector("template[data-slhx]"); + const template = firstElement(doc, (el) => el.tagName === "TEMPLATE" && el.hasAttribute("data-slhx")); const lowered = replaceLoweredSlots(scope, doc); const named = replaceSlot(scope, doc, "content", lowered ? undefined : (template ? template.innerHTML : html)); if (!lowered && !named) { @@ -371,16 +428,18 @@ return false; } replaceSlot(scope, doc, "nav"); - const nextTitle = title || (doc.querySelector("title") && doc.querySelector("title").textContent); + const titleEl = firstElement(doc, (el) => el.tagName === "TITLE"); + const nextTitle = title || (titleEl && titleEl.textContent); if (nextTitle) document.title = nextTitle; return true; } function replaceLoweredSlots(scope, doc) { let changed = false; - doc.querySelectorAll("[data-sid], [data-slot-id]").forEach((source) => { + forEachElement(doc.body || doc, (source) => { const id = source.getAttribute("data-sid") || source.getAttribute("data-slot-id"); - const target = scope.querySelector(`[data-sid="${cssEscape(id)}"], [data-slot-id="${cssEscape(id)}"]`); + if (!id) return; + const target = generatedTarget(scope, id); if (target) { target.innerHTML = source.innerHTML; changed = true; @@ -389,10 +448,26 @@ return changed; } + function forEachElement(scope, visit) { + for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) { + visit(node); + forEachElement(node, visit); + } + } + + function generatedFormTarget(scope, id) { + if (scope && generatedForm(scope, id)) return scope; + return firstElement(scope, (el) => generatedForm(el, id)); + } + + function namedSlot(el, name) { + return attrEquals(el, "data-slhx-slot", name) || attrEquals(el, "data-slot", name); + } + function replaceSlot(scope, doc, name, fallback) { - const target = scope.querySelector(`[data-slhx-slot="${name}"], [data-slot="${name}"]`); + const target = firstElement(scope, (el) => namedSlot(el, name)); if (!target) return false; - const source = doc.querySelector(`[data-slhx-slot="${name}"], [data-slot="${name}"]`); + const source = firstElement(doc, (el) => namedSlot(el, name)); if (!source && fallback === undefined) return false; target.innerHTML = source ? source.innerHTML : fallback; return true; @@ -412,7 +487,7 @@ ["click", "submit", "input", "change", "dragstart", "dragover", "drop"].forEach((name) => { root.addEventListener(name, (event) => { if (name === "dragstart") { - const item = closestInRoot(event.target, root, "[data-key]"); + const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-key")); if (item) { dragKeys.set(root, item.getAttribute("data-key")); if (event.dataTransfer) event.dataTransfer.setData("text/plain", item.getAttribute("data-key")); @@ -420,18 +495,21 @@ return; } if (name === "dragover") { - const drop = closestInRoot(event.target, root, `[${HID}][data-slhx-on="drop"]`); + const drop = closestInRoot(event.target, root, (el) => el.hasAttribute(HID) && el.getAttribute("data-slhx-on") === "drop"); if (drop) event.preventDefault(); return; } - const nav = closestInRoot(event.target, root, "a[data-slhx-nav], [data-slhx-boost] a[href]"); + const nav = closestInRoot(event.target, root, (el) => + (el.tagName === "A" && el.hasAttribute("data-slhx-nav")) || + (el.tagName === "A" && el.hasAttribute("href") && closestInRoot(el.parentElement, root, (node) => node.hasAttribute("data-slhx-boost"))) + ); if (name === "click" && nav && sameOriginNav(event, nav)) { event.preventDefault(); navigate(nav); return; } if (name === "click") { - const direct = closestInRoot(event.target, root, `[${HID}]`); + const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID)); if (direct && defaultEvent(direct) === "click") { event.preventDefault(); schedule(direct, name); @@ -439,7 +517,10 @@ } } if (name === "click") { - const submitter = closestInRoot(event.target, root, "button[type=submit], input[type=submit], button:not([type])"); + const submitter = closestInRoot(event.target, root, (el) => + (el.tagName === "BUTTON" && (!el.hasAttribute("type") || el.getAttribute("type") === "submit")) || + (el.tagName === "INPUT" && el.getAttribute("type") === "submit") + ); const form = formOwner(submitter); if (form && root.contains(form) && formHandleId(form)) { if (form.reportValidity && !form.reportValidity()) return; @@ -448,7 +529,10 @@ return; } } - let el = closestInRoot(event.target, root, `[${HID}], [data-slhx-boost] form`); + let el = closestInRoot(event.target, root, (node) => + node.hasAttribute(HID) || + (node.tagName === "FORM" && closestInRoot(node.parentElement, root, (parent) => parent.hasAttribute("data-slhx-boost"))) + ); if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && formHandleId(event.target)) el = event.target; if (!el || defaultEvent(el) !== name) return; event.preventDefault(); @@ -473,8 +557,8 @@ } function bindPolling(root) { - root.querySelectorAll("[data-slhx-every]").forEach((el) => { - if (timers.has(el)) return; + forEachElement(root, (el) => { + if (!el.hasAttribute("data-slhx-every") || timers.has(el)) return; const ms = duration(el.getAttribute("data-slhx-every")); if (!ms) return; timers.set(el, setInterval(() => document.contains(el) ? send(el, "every") : clearInterval(timers.get(el)), ms)); diff --git a/slhx-js/tests/runtime.rs b/slhx-js/tests/runtime.rs index 3cc21b8..8fc0550 100644 --- a/slhx-js/tests/runtime.rs +++ b/slhx-js/tests/runtime.rs @@ -26,9 +26,11 @@ fn runtime_targets_generated_resources_not_response_selectors() { let source = slhx_js::RUNTIME_JS; assert!(source.contains("function targetFor(scope, ref)")); - assert!(source.contains("return scope.querySelector(`[data-sid=\"${ref.resource.id}\"], [data-slot-id=\"${ref.resource.id}\"]`)")); - assert!(source.contains("return scope.querySelector(`[data-sid=\"${id}\"][data-key=\"${escapedKey}\"], [data-sid=\"${id}\"] [data-key=\"${escapedKey}\"], [data-slot-id=\"${id}\"][data-key=\"${escapedKey}\"], [data-slot-id=\"${id}\"] [data-key=\"${escapedKey}\"]`)")); - assert!(source.contains("doc.querySelectorAll(\"[data-sid], [data-slot-id]\")")); + assert!(source.contains("return generatedTarget(scope, ref.resource.id)")); + assert!(source.contains("function firstElement(scope, predicate)")); + assert!(source.contains("function generatedResource(el, id)")); + assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-key\", key) && withinGeneratedResource(el, scope, id))")); + assert!(source.contains("const target = generatedTarget(scope, id)")); assert!(source.contains("if (!target) return missing(scope, op.target)")); assert!(!source.contains("data-slhx-target")); assert!(!source.contains("data-slhx-select")); @@ -39,8 +41,9 @@ fn runtime_interval_dispatch_avoids_duplicate_timers() { // req: convention/005 let source = slhx_js::RUNTIME_JS; - assert!(source.contains("root.querySelectorAll(\"[data-slhx-every]\")")); - assert!(source.contains("if (timers.has(el)) return")); + assert!(source.contains("forEachElement(root, (el) =>")); + assert!(source.contains("!el.hasAttribute(\"data-slhx-every\") || timers.has(el)")); + assert!(source.contains("if (!el.hasAttribute(\"data-slhx-every\") || timers.has(el)) return")); assert!(source.contains("setInterval(() => document.contains(el) ? send(el, \"every\")")); assert!(source.contains("clearInterval(timers.get(el))")); } @@ -53,9 +56,11 @@ fn runtime_toggles_pending_conventions_around_requests() { assert!(source.contains("function showPending(el, on)")); assert!(source.contains("el.getAttribute(\"data-slhx-pending-class\")")); assert!(source.contains("el.classList.toggle(klass, on)")); - assert!(source.contains("root.querySelectorAll(\"[data-slhx-indicator]\")")); + assert!(source.contains("forEachElement(root, (i) => { if (i.hasAttribute(\"data-slhx-indicator\")) i.hidden = !on; })")); assert!(source.contains("i.hidden = !on")); assert!(source.contains("el.hasAttribute(\"data-slhx-disable-while-pending\")")); + assert!(source.contains("if (isDisableControl(el)) controls.push(el)")); + assert!(source.contains("forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); })")); assert!(source.contains("c.disabled = on")); assert!(source.contains("showPending(target, true)")); assert!(source.contains("showPending(target, false)")); @@ -95,8 +100,9 @@ fn runtime_clicking_submitter_schedules_form_submit() { assert!(source.contains("function formOwner(el)")); assert!(source.contains("function formHandleId(form)")); assert!(source.contains("document.getElementById(el.getAttribute(\"form\"))")); - assert!(source.contains("const direct = closestInRoot(event.target, root, `[${HID}]`)")); - assert!(source.contains("button[type=submit], input[type=submit], button:not([type])")); + assert!(source.contains("const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID))")); + assert!(source.contains("const submitter = closestInRoot(event.target, root, (el) =>")); + assert!(source.contains("el.tagName === \"BUTTON\" && (!el.hasAttribute(\"type\") || el.getAttribute(\"type\") === \"submit\")")); assert!(source.contains("form.reportValidity && !form.reportValidity()")); assert!(source.contains("schedule(form, \"submit\")")); } @@ -187,7 +193,8 @@ fn runtime_page_swaps_lowered_slot_ids() { let source = slhx_js::RUNTIME_JS; assert!(source.contains("function replaceLoweredSlots(scope, doc)")); - assert!(source.contains("doc.querySelectorAll(\"[data-sid], [data-slot-id]\")")); + assert!(source.contains("forEachElement(doc.body || doc, (source)")); + assert!(source.contains("const target = generatedTarget(scope, id)")); assert!(source.contains("target.innerHTML = source.innerHTML")); } @@ -196,9 +203,9 @@ fn runtime_keeps_form_field_targets_separate_from_error_targets() { let source = slhx_js::RUNTIME_JS; assert!(source.contains("function fieldTarget(scope, id, field)")); - assert!(source.contains("[data-fid=\"${id}\"] [name=\"${escapedField}\"]")); + assert!(source.contains("attrEquals(el, \"name\", field) && withinGeneratedForm(el, scope, id)")); assert!(source.contains("function formErrorTarget(scope, id, field)")); - assert!(source.contains("[data-slhx-error-for=\"${escapedField}\"]")); + assert!(source.contains("attrEquals(el, \"data-slhx-error-for\", field) && withinGeneratedForm(el, scope, id)")); } #[test]