From 54fe5362dc6432026ae997be16a6da606fb67ade Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:35:49 +0000 Subject: [PATCH 01/10] Initial plan From bc645c4a7285a080bd59f004db6ee0d9fdd50745 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:48:34 +0000 Subject: [PATCH 02/10] feat: add shadow modes, implicit slots, and :_wc per-host state to dmWc - Shadow modes: ^dom.open / ^dom.closed mods on data-m-wc - Slots: implicit projection in light DOM when template has - :_wc signal path root: per-host state lives on host._wc - Public helpers: dmGetHost(host, path) / dmSetHost(host, path, val) - wireItClone now handles ShadowRoot nodes - WC prop dispatch works into shadow root children Closes #142 --- dmax.js | 172 +++++++++++++++++++++++++++++++----- package.json | 3 +- tests/dmWcV2.e2e.js | 145 ++++++++++++++++++++++++++++++ tests/dmax.size.limits.json | 4 +- 4 files changed, 301 insertions(+), 23 deletions(-) create mode 100644 tests/dmWcV2.e2e.js diff --git a/dmax.js b/dmax.js index 73f8236..025fe3c 100644 --- a/dmax.js +++ b/dmax.js @@ -70,6 +70,10 @@ const E_RW_EV = `dmEx ${MOD}${M_RW} event is not found in trigger:` const E_TRIG_EL = 'Element is not found in trigger:', E_TRIG_EV = 'Event is not found in trigger:', E_FORM_EL = 'Form element is not found for trigger:' const IT_STATES = new WeakMap(), IT_ATTRS = new WeakMap() + const WC_ROOT = '_wc' + const _wcSubs = new WeakMap() // per-host subscriptions for :_wc signals + const getWcHost = (el) => { let n = el; while (n) { if (n._wc !== undefined || (n.tagName && n.tagName.indexOf('-') >= 0)) return n; n = n.parentNode && n.parentNode.nodeType === 11 ? n.parentNode.host : n.parentNode } return el } + const getWcStore = (host) => host._wc || (host._wc = Object.create(null)) const isSp = (n) => { if (n.startsWith(SP)) for (const s of SPS) if (n.startsWith(s, 1)) return true; return false } const mkIt = (kind, not, root, path, mods = NIL) => ({ kind, not, root, path, mods, sp: kind === SP ? SP_DEFS[root] || null : null, isSi: kind === SI, isEv: kind === EP, isSp: kind === SP, isImmediate: null }) const mkMod = (not, root, path) => ({ not, root, path, isImmediate: root === M_IMMEDIATE ? true : root === M_NOT_IMMEDIATE ? false : null }) @@ -212,6 +216,7 @@ // - data-m-si:foo='{bar: "hey"}' // foo signal // - data-m-si:foo:baz='`js expr ${42}`' // eval expr as Function body and set to all signals // - data-m-si:foo='el.Value * dm.bar' // you may use other signals and element props + // - data-m-si:_wc='{"count": 0}' // per-host state const dmSi = (el, dKey, dVal) => { const it = parseCached(dKey), tars = it[TARG] if (it[MOD].length || it[TRIG].length || it[ADD].length) warn('targets only:', dKey) @@ -226,7 +231,11 @@ for (const t of tars) { if (t.kind != SI) { logErr('signal targets only:', t, dKey); continue } if (t.mods.length) warn('mods ignored:', t.mods, dKey) - _dm.set(t.root, val) + if (t.root === WC_ROOT) { + const store = getWcStore(el) + if (val && typeof val === 'object') Object.assign(store, val) + else el._wc = val + } else _dm.set(t.root, val) } } @@ -427,8 +436,14 @@ const PERMIT_MODS = Object.assign(noProto(), { [M_AND]: 1, [M_EQ]: 1, [M_NE]: 1, [M_LT]: 1, [M_GT]: 1, [M_LE]: 1, [M_GE]: 1 }) - const getSiVal = (it) => { - const sig = _dm.get(it.root) + const getSiVal = (it, el) => { + const root = it.root + if (root === WC_ROOT) { + const host = el ? getWcHost(el) : null + const sig = host ? host._wc : undefined + return it.path ? getPrValAndDepth(sig, it.path)[0] : sig + } + const sig = _dm.get(root) const path = it.path return path ? getPrValAndDepth(sig, path)[0] : sig } @@ -579,7 +594,7 @@ const itAttrs = IT_ATTRS.get(el) if (itAttrs && itAttrs.length) { for (let i = 0; i < itAttrs.length; ++i) globalThis.wireNode(el, itAttrs[i][0], itAttrs[i][1]) - } else { + } else if (el.attributes) { const attrs = el.attributes for (let i = 0; i < attrs.length; ++i) { const attr = attrs[i] @@ -587,7 +602,7 @@ } } const children = el.children - for (let i = children.length - 1; i >= 0; --i) stack.push(children[i]) + if (children) for (let i = children.length - 1; i >= 0; --i) stack.push(children[i]) } } const expected = (v, ...msg) => v || (warn(...msg), null) @@ -673,6 +688,13 @@ return list } const removeSiSub = (sub) => { + if (sub.trig.root === WC_ROOT) { + const host = getWcHost(sub.el) + const subs = _wcSubs.get(host) + if (!subs || !subs.length) return + for (let i = 0; i < subs.length; ++i) if (subs[i] === sub) { subs.splice(i, 1); return } + return + } const subs = _subs.get(sub.trig.root) if (!subs || !subs.length) return for (let i = 0; i < subs.length; ++i) if (subs[i] === sub) { subs.splice(i, 1); return } @@ -695,8 +717,8 @@ } const PASSIVE_LISTENER_OPTS = Object.freeze({ passive: true }) const ELEMENT_NODE = 1 - const invokeSub = (fn, detail, trVal, el, tr) => fn(DM, el, tr, tr.isSi ? getSiVal(tr) : trVal, detail) - const invokeBoundSub = (sub, detail = null) => sub.fn(DM, sub.el, sub.trig, sub.trig.isSi ? getSiVal(sub.trig) : null, detail) + const invokeSub = (fn, detail, trVal, el, tr) => fn(DM, el, tr, tr.isSi ? getSiVal(tr, el) : trVal, detail) + const invokeBoundSub = (sub, detail = null) => sub.fn(DM, sub.el, sub.trig, sub.trig.isSi ? getSiVal(sub.trig, sub.el) : null, detail) const onIntervalSub = (sub) => { const detail = { tick: sub.tick++, ms: sub.ms, type: SP_INTERVAL } try { invokeSub(sub.fn, detail, sub.ms, sub.el, sub.trig) } @@ -789,7 +811,13 @@ if (tr.isSi) { const sub = { el, trig: tr, fn, siChangeM: mod.c, ev: null, clearId: null } sub.fn = applyTrMs(fn, tr, mod, sub, el) - upsert(_subs, tr.root).push(sub), (elSubs || upsert(_cleanupBoundSubs, el)).push(sub) + if (tr.root === WC_ROOT) { + const host = getWcHost(el) + upsert(_wcSubs, host).push(sub) + } else { + upsert(_subs, tr.root).push(sub) + } + ;(elSubs || upsert(_cleanupBoundSubs, el)).push(sub) return sub } const sp = tr.sp @@ -819,9 +847,50 @@ }) } - const setSiAndNotifySubs = (dKey, tar, val) => { + const setSiAndNotifySubs = (dKey, tar, val, wcEl) => { const root = tar?.root, path = tar?.path if (!root) return null + + // Per-host _wc signal + if (root === WC_ROOT) { + const host = wcEl ? getWcHost(wcEl) : null + if (!host) return null + const store = getWcStore(host) + let siVal = store, curVal = siVal, parent = siVal, d = 0, last = null + if (path) { + if (!path.length) return null + for (; d < path.length - 1; ++d) parent = parent[path[d]] && typeof parent[path[d]] === 'object' ? parent[path[d]] : (parent[path[d]] = {}) + curVal = parent[last = path[d]] + } + if (!valChangedDeep(curVal, val)) return + const handlers = _wcSubs.get(host) + if (!handlers) { + if (!path) host._wc = val + else parent[last] = val + return + } + let collected = [] + for (const h of handlers) { + const hp = h.trig.path + if (!hp) { collected.push([h, null]); continue } + if (path) { + const pl = path.length, hl = hp.length, minLen = hl < pl ? hl : pl + let i = 0 + for (; i < minLen && path[i] == hp[i]; ++i); + if (i < minLen) continue + if (hl < pl || pl == hl) { collected.push([h, null]); continue } + } + const pathCur = getPrValAndDepth(siVal, hp)[0] + const pathVal = getPrValAndDepth(val, hp)?.[0] ?? getPrValAndDepth(path ? val : store, hp)?.[0] + if (pathCur !== undefined && !valChangedDeep(pathCur, pathVal)) continue + collected.push([h, null]) + } + if (!path) host._wc = val + else parent[last] = val + for (const col of collected) invokeBoundSub(col[0], null) + return + } + let siVal = _dm.get(root), curVal = siVal, parent = siVal, d = 0, last = null if (path) { if (!path.length) return null @@ -891,9 +960,9 @@ } let syncDepth=0,MAX_SYNC_DEPTH=32; - const setSiAndNotifySubsNDeep = (dKey, tar, val) => { + const setSiAndNotifySubsNDeep = (dKey, tar, val, wcEl) => { if (syncDepth++ > MAX_SYNC_DEPTH) return logErr(`Error: Infinite loop detected for signal: ${tar} (depth > ${MAX_SYNC_DEPTH}) in ${dKey}`) - try { return setSiAndNotifySubs(dKey, tar, val) } finally { syncDepth-- } + try { return setSiAndNotifySubs(dKey, tar, val, wcEl) } finally { syncDepth-- } } /** @@ -949,7 +1018,7 @@ } let trVal if (isSig) { - trVal = providedVal ?? getSiVal(trIt) + trVal = providedVal ?? getSiVal(trIt, el) if (hasPipeline) trVal = runRm(trVal, el, mod, null) } else { trVal = hasPipeline ? getTrVal(detail, readEl || el, mod, null) : (providedVal ?? getEvVal(detail)) @@ -1010,7 +1079,7 @@ for (const prTr of writePrTrs) { const writeSi = (dm, _el, syncTr, trigVal, detail) => { const exprVal = fn(dm, el, syncTr, trigVal, detail) - for (const siTr of writeSiTrs) setSiAndNotifySubsNDeep(dKey, siTr[0], combineActResult(getSiVal(siTr[0]), exprVal, siTr[1])) + for (const siTr of writeSiTrs) setSiAndNotifySubsNDeep(dKey, siTr[0], combineActResult(getSiVal(siTr[0], el), exprVal, siTr[1]), el) } const moddedHandler = addTrSub(el, prTr.tr, prTr.mod, writeSi, elSubs, prTr.taEl, prTr.ev, prTr.prPath, prTr.readPath, prTr.readEl) if (prTr.tr.isImmediate != false) invokeSub(moddedHandler, null, getReadVal(prTr.readEl, prTr.mod, prTr.readPath), el, prTr.tr) @@ -1033,8 +1102,8 @@ for (const tar of tars) { failedTa = tar const outVal = tar._j ? dmJsos(exprVal) : exprVal - const nextVal = tar.isSi ? combineActResult(getSiVal(tar), outVal, tar._m) : combineActResult(getElPrVal(tar._el || el, tar.path), outVal, tar._m) - if (tar.isSi) setSiAndNotifySubsNDeep(dKey, tar, nextVal) + const nextVal = tar.isSi ? combineActResult(getSiVal(tar, el), outVal, tar._m) : combineActResult(getElPrVal(tar._el || el, tar.path), outVal, tar._m) + if (tar.isSi) setSiAndNotifySubsNDeep(dKey, tar, nextVal, el) else setPr(el, dKey, tar, nextVal) } } catch (e) { logErr('Error: setting target', failedTa, 'in', dKey, 'ended with ex:', e) } @@ -1194,6 +1263,20 @@ globalThis.dmSet = dmSet globalThis.dmSub = dmSub globalThis.dmSel = dmSel, globalThis.dmSelAll = dmSelAll, globalThis.dmEl = dmEl + globalThis.dmGetHost = (host, path) => { + if (!host || !host._wc) return undefined + if (!path) return host._wc + const parts = typeof path === 'string' ? path.split('.') : path + return getPrValAndDepth(host._wc, parts)[0] + } + globalThis.dmSetHost = (host, path, val) => { + if (!host) return val + const store = getWcStore(host) + const parts = typeof path === 'string' ? path.split('.') : path + if (!parts || !parts.length) { host._wc = val; return val } + setSiAndNotifySubs('dmSetHost', { root: WC_ROOT, path: parts }, val, host) + return val + } // - data-m-it@posts // - data-m-it+#tpl-post@posts @@ -1414,21 +1497,70 @@ // - dmSet('go', 1) const dmActApi = (el, dKey, dVal) => bindAddedSubs(el, (host) => dmAct(host, getApiDKey(dKey, ''), dVal)) const WC_TMPLS = new WeakSet(), WC_INITS = new WeakSet(), WC_PROP_RE = /[^,\s]+/g - const defWc = (tpl, name) => { + const WC_DOM_OPEN = 'dom.open', WC_DOM_CLOSED = 'dom.closed' + const projectSlots = (host, target) => { + const slots = target.querySelectorAll('slot') + if (!slots.length) return + const children = Array.from(host.childNodes) + for (const slot of Array.from(slots)) { + const name = slot.getAttribute('name') + let projected = false + for (let i = children.length - 1; i >= 0; --i) { + const ch = children[i] + if (name ? (ch.getAttribute && ch.getAttribute('slot') === name) : (ch.nodeType === 1 ? !ch.hasAttribute('slot') : ch.nodeType === 3)) { + slot.parentNode.insertBefore(ch, slot) + children.splice(i, 1) + projected = true + } + } + if (projected) slot.parentNode.removeChild(slot) + } + } + const defWc = (tpl, name, mods) => { if (!name || name.indexOf('-') < 0) return logErr('dmWc template expects custom-element name value:', name) if (customElements.get(name) || WC_TMPLS.has(tpl)) return tpl WC_TMPLS.add(tpl) + const useShadow = mods && (mods.has(WC_DOM_OPEN) || mods.has(WC_DOM_CLOSED)) + const shadowMode = useShadow ? (mods.has(WC_DOM_CLOSED) ? 'closed' : 'open') : null + const hasSlots = !shadowMode && tpl.content && tpl.content.querySelector('slot') const props = (tpl.getAttribute(DM_KEY + 'wc-props') || '').match(WC_PROP_RE) || NIL - const WC = class extends HTMLElement { connectedCallback() { if (WC_INITS.has(this)) return; WC_INITS.add(this); if (!this.firstElementChild && tpl.content) this.appendChild(tpl.content.cloneNode(true)), wireItClone(this); for (const p of props) { let v = this['$' + p]; if (hasOwn(this, p)) v = this[p], delete this[p]; v !== undefined && (this[p] = v) } } } - for (const p of props) Object.defineProperty(WC.prototype, p, { get() { return this['$' + p] }, set(v) { this['$' + p] = v, this.dispatchEvent(new CustomEvent(p, { detail: v })); for (let ch = this.firstElementChild; ch; ch = ch.nextElementSibling) ch.dispatchEvent(new CustomEvent(p, { detail: v })) } }) + const WC = class extends HTMLElement { connectedCallback() { + if (WC_INITS.has(this)) return; WC_INITS.add(this) + const target = shadowMode ? this.attachShadow({ mode: shadowMode }) : this + if ((shadowMode || hasSlots || !this.firstElementChild) && tpl.content) { + const clone = tpl.content.cloneNode(true) + if (hasSlots) { + const wrapper = document.createElement('div') + wrapper.appendChild(clone) + projectSlots(this, wrapper) + while (wrapper.firstChild) target.appendChild(wrapper.firstChild) + } else target.appendChild(clone) + wireItClone(target) + } + for (const p of props) { let v = this['$' + p]; if (hasOwn(this, p)) v = this[p], delete this[p]; v !== undefined && (this[p] = v) } + } } + for (const p of props) Object.defineProperty(WC.prototype, p, { get() { return this['$' + p] }, set(v) { this['$' + p] = v, this.dispatchEvent(new CustomEvent(p, { detail: v })); const root = this.shadowRoot || this; for (let ch = root.firstElementChild; ch; ch = ch.nextElementSibling) ch.dispatchEvent(new CustomEvent(p, { detail: v })) } }) customElements.define(name, WC) return tpl } const toWcTpl = (html, props, tpl = document.createElement('template')) => (props != null && tpl.setAttribute(DM_KEY + 'wc-props', Array.isArray(props) ? props.join(' ') : '' + props), tpl.innerHTML = html, tpl) + const parseWcMods = (dKey) => { + const mods = new Set() + const rest = dKey.slice(DM_KEY.length) + let p = rest.indexOf('^') + while (p >= 0) { + const end = indexFirst(rest, ['^', ':', '@'], p + 1) + const mod = rest.slice(p + 1, end < 0 ? rest.length : end) + if (mod) mods.add(mod) + p = end < 0 ? -1 : rest[end] === '^' ? end : -1 + } + return mods.size ? mods : null + } // - dmWc('my-card','
'), dmWc(tplEl,'my-card') - const dmWc = (nameOrTpl, htmlOrName, props) => nameOrTpl && nameOrTpl.tagName === 'TEMPLATE' ? defWc(nameOrTpl, htmlOrName && htmlOrName.trim()) : typeof htmlOrName === 'string' ? defWc(typeof nameOrTpl === 'string' ? toWcTpl(htmlOrName, props) : nameOrTpl, nameOrTpl && nameOrTpl.trim()) : logErr('dmWc expects (name, html[, props]) or (templateEl, name):', nameOrTpl) + const dmWc = (nameOrTpl, htmlOrName, props, mods) => nameOrTpl && nameOrTpl.tagName === 'TEMPLATE' ? defWc(nameOrTpl, htmlOrName && htmlOrName.trim(), mods) : typeof htmlOrName === 'string' ? defWc(typeof nameOrTpl === 'string' ? toWcTpl(htmlOrName, props) : nameOrTpl, nameOrTpl && nameOrTpl.trim(), mods) : logErr('dmWc expects (name, html[, props]) or (templateEl, name):', nameOrTpl) // - - const dmWcAttr = (el, dKey, dVal) => el.tagName === 'TEMPLATE' ? dmWc(el, dVal) : logErr('Error: dmWc is template-only; use data-m-ex for WC host props in:', dKey) + // - + const dmWcAttr = (el, dKey, dVal) => el.tagName === 'TEMPLATE' ? dmWc(el, dVal, null, parseWcMods(dKey)) : logErr('Error: dmWc is template-only; use data-m-ex for WC host props in:', dKey) const dmNo = () => {} globalThis.dmAct = dmActApi globalThis.dmWc = dmWc diff --git a/package.json b/package.json index 103e0e7..89bd2a4 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,11 @@ "test:naming": "node tests/dmax.naming.js", "test:notebook": "node tests/notebook.asserts.e2e.js", "test:wc": "node tests/dmWc.e2e.js", + "test:wc-v2": "node tests/dmWcV2.e2e.js", "test:auto-scan": "node tests/dmax.autoscan.e2e.js", "test:m-ex-cel": "node tests/m-ex-cel.e2e.js", "test:style": "node tests/dmStyle.e2e.js", - "test": "npm run test:actions && npm run test:fuzz && npm run test:headless && npm run test:size && npm run test:naming && npm run test:notebook && npm run test:wc && npm run test:auto-scan && npm run test:m-ex-cel && npm run test:style", + "test": "npm run test:actions && npm run test:fuzz && npm run test:headless && npm run test:size && npm run test:naming && npm run test:notebook && npm run test:wc && npm run test:wc-v2 && npm run test:auto-scan && npm run test:m-ex-cel && npm run test:style", "serve": "npx http-server -c-1 -p 8080" }, "name": "dmax", diff --git a/tests/dmWcV2.e2e.js b/tests/dmWcV2.e2e.js new file mode 100644 index 0000000..115fa79 --- /dev/null +++ b/tests/dmWcV2.e2e.js @@ -0,0 +1,145 @@ +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const { JSDOM } = require('jsdom') + +function waitFor(conditionFn, timeout = 5000, interval = 20) { + const start = Date.now() + return new Promise((resolve, reject) => { + ;(function poll() { + try { + const value = conditionFn() + if (value) return resolve(value) + } catch (err) { return reject(err) } + if (Date.now() - start > timeout) return reject(new Error('timeout')) + setTimeout(poll, interval) + })() + }) +} + +;(async () => { + const src = fs.readFileSync(path.join(process.cwd(), 'dmax.js'), 'utf8') + const dom = new JSDOM(``, { runScripts: 'dangerously', pretendToBeVisual: true }) + const { window } = dom + const { document } = window + + await waitFor(() => typeof window.dmWc === 'function') + + // === Test 1: Shadow mode ^dom.open === + window.dmWc('x-shadow-open', '

shadow-open

', null, new Set(['dom.open'])) + const shadowOpen = document.createElement('x-shadow-open') + document.body.append(shadowOpen) + await waitFor(() => shadowOpen.shadowRoot) + assert(shadowOpen.shadowRoot, 'open shadow root exists') + assert(shadowOpen.shadowRoot.querySelector('p').textContent === 'shadow-open', 'shadow content rendered') + console.log('PASS: ^dom.open creates open shadow root') + + // === Test 2: Shadow mode ^dom.closed === + window.dmWc('x-shadow-closed', '

shadow-closed

', null, new Set(['dom.closed'])) + const shadowClosed = document.createElement('x-shadow-closed') + document.body.append(shadowClosed) + // closed shadow root means shadowRoot is null from outside + await waitFor(() => !shadowClosed.shadowRoot && !shadowClosed.querySelector('p')) + assert(shadowClosed.shadowRoot === null, 'closed shadow root is null from outside') + console.log('PASS: ^dom.closed creates closed shadow root (null from outside)') + + // === Test 3: Default is light DOM (no shadow root) === + window.dmWc('x-light', '

light-content

') + const lightEl = document.createElement('x-light') + document.body.append(lightEl) + await waitFor(() => lightEl.querySelector('p')) + assert(lightEl.shadowRoot === null, 'no shadow root for light DOM') + assert(lightEl.querySelector('p').textContent === 'light-content', 'light DOM content rendered') + console.log('PASS: default is light DOM (no shadow root)') + + // === Test 4: Slots (implicit, light DOM) === + window.dmWc('x-slot', '
') + const slotEl = document.createElement('x-slot') + slotEl.innerHTML = 'projected' + document.body.append(slotEl) + await waitFor(() => slotEl.querySelector('.wrapper span')) + assert(slotEl.querySelector('.wrapper span').textContent === 'projected', 'default slot projects children') + console.log('PASS: implicit slot projection (light DOM)') + + // === Test 5: Named slots === + window.dmWc('x-named-slot', '
fallback
') + const namedSlotEl = document.createElement('x-named-slot') + namedSlotEl.innerHTML = 'My Title

Body content

' + document.body.append(namedSlotEl) + await waitFor(() => namedSlotEl.querySelector('header span')) + assert(namedSlotEl.querySelector('header span').textContent === 'My Title', 'named slot projects matching child') + assert(namedSlotEl.querySelector('main p').textContent === 'Body content', 'default slot projects unslotted child') + console.log('PASS: named slot projection') + + // === Test 6: Slot fallback content preserved when no matching child === + window.dmWc('x-fallback-slot', '
fallback-text
') + const fallbackEl = document.createElement('x-fallback-slot') + document.body.append(fallbackEl) + await waitFor(() => fallbackEl.querySelector('div')) + assert(fallbackEl.querySelector('div slot[name="missing"]').textContent === 'fallback-text', 'fallback content preserved when no matching child') + console.log('PASS: slot fallback content preserved') + + // === Test 7: :_wc per-host state initialization === + window.dmWc('x-counter', '') + document.body.insertAdjacentHTML('beforeend', '') + document.body.insertAdjacentHTML('beforeend', '') + window.dmScan() + const c1 = document.getElementById('c1') + const c2 = document.getElementById('c2') + await waitFor(() => c1._wc && c2._wc) + assert(c1._wc.count === 10, 'c1 _wc initialized') + assert(c2._wc.count === 20, 'c2 _wc initialized') + console.log('PASS: :_wc per-host state initialization') + + // === Test 8: :_wc state is independent between instances === + await waitFor(() => c1.querySelector('span') && c1.querySelector('span').textContent === '10') + await waitFor(() => c2.querySelector('span') && c2.querySelector('span').textContent === '20') + assert(c1.querySelector('span').textContent === '10', 'c1 reads own _wc.count') + assert(c2.querySelector('span').textContent === '20', 'c2 reads own _wc.count') + console.log('PASS: :_wc state independent between instances') + + // === Test 9: dmGetHost / dmSetHost helpers === + assert(window.dmGetHost(c1, 'count') === 10, 'dmGetHost reads c1.count') + assert(window.dmGetHost(c2, 'count') === 20, 'dmGetHost reads c2.count') + window.dmSetHost(c1, 'count', 42) + assert(c1._wc.count === 42, 'dmSetHost updates c1._wc.count') + assert(c2._wc.count === 20, 'dmSetHost does not affect c2') + console.log('PASS: dmGetHost / dmSetHost public helpers') + + // === Test 10: dmSetHost triggers re-render === + await waitFor(() => c1.querySelector('span') && c1.querySelector('span').textContent === '42') + assert(c1.querySelector('span').textContent === '42', 'dmSetHost triggers re-render of bound elements') + console.log('PASS: dmSetHost triggers re-render') + + // === Test 11: :_wc on regular element (non-WC) === + document.body.insertAdjacentHTML('beforeend', '
') + window.dmScan() + const regular = document.getElementById('regular') + await waitFor(() => regular._wc && regular._wc.x === 1) + assert(regular._wc.x === 1, ':_wc works on regular element') + await waitFor(() => regular.querySelector('span') && regular.querySelector('span').textContent === '1') + console.log('PASS: :_wc on regular element') + + // === Test 12: data-m-wc^dom.open via attribute (dmWcAttr path) === + document.body.insertAdjacentHTML('beforeend', '') + window.dmScan() + const attrShadow = document.querySelector('x-attr-shadow') + await waitFor(() => attrShadow && attrShadow.shadowRoot) + assert(attrShadow.shadowRoot, 'dmWcAttr with ^dom.open creates shadow root') + assert(attrShadow.shadowRoot.querySelector('p').textContent === 'attr-shadow', 'shadow content from attribute') + console.log('PASS: data-m-wc^dom.open via attribute') + + // === Test 13: WC prop dispatch into shadow root === + window.dmWc('x-shadow-prop', '
', 'msg', new Set(['dom.open'])) + const shadowProp = document.createElement('x-shadow-prop') + shadowProp.msg = 'hello-shadow' + document.body.append(shadowProp) + await waitFor(() => shadowProp.shadowRoot && shadowProp.shadowRoot.querySelector('div') && shadowProp.shadowRoot.querySelector('div').textContent === 'hello-shadow') + assert(shadowProp.shadowRoot.querySelector('div').textContent === 'hello-shadow', 'prop dispatch works into shadow root') + console.log('PASS: WC prop dispatch into shadow root') + + console.log('\n=== All dmWc v2 tests passed ===') +})().catch((err) => { + console.error(err && err.stack ? err.stack : err) + process.exit(1) +}) diff --git a/tests/dmax.size.limits.json b/tests/dmax.size.limits.json index 7b9ad18..f2511ee 100644 --- a/tests/dmax.size.limits.json +++ b/tests/dmax.size.limits.json @@ -1,4 +1,4 @@ { - "lines": 1818, - "bytes": 93982 + "lines": 1950, + "bytes": 99747 } From 9985d58cd01d4bcd20ae699da85078a0d5f2e2a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:44:19 +0000 Subject: [PATCH 03/10] feat: merge patch - unified WC host resolution, shadow panel CSS, better tests --- dm-style.js | 7 +- dmax.js | 424 ++++++++++++++++++++++-------------- package.json | 4 +- tests/dmHostScope.e2e.js | 291 +++++++++++++++++++++++++ tests/dmStyle.e2e.js | 47 ++-- tests/dmWcV2.e2e.js | 145 ------------ tests/dmax.size.limits.json | 4 +- tests/m-ex-cel.e2e.js | 32 +-- wc.md | 41 ++++ 9 files changed, 660 insertions(+), 335 deletions(-) create mode 100644 tests/dmHostScope.e2e.js delete mode 100644 tests/dmWcV2.e2e.js diff --git a/dm-style.js b/dm-style.js index b01f002..4dc71c0 100644 --- a/dm-style.js +++ b/dm-style.js @@ -138,12 +138,17 @@ title: opts.title, note: opts.note, }) + // Shadow-mode panel CSS — selectors are naturally scoped to the shadow root, + // so the legacy `${name}` interpolations are no longer needed. ~600 chars saved. + const panelShadowCss = () => `details{position:relative;display:block}button{all:unset;box-sizing:border-box}summary{display:grid;list-style:none;cursor:pointer;-webkit-tap-highlight-color:transparent;touch-action:manipulation}summary::-webkit-details-marker{display:none}.fab{display:grid;place-items:center;inline-size:34px;block-size:34px;border-radius:999px;background:#0f172acc;color:#fff;box-shadow:0 8px 24px #0003;backdrop-filter:blur(10px)}details[open] .fab{background:#2563ebd9}.panel{position:absolute;top:42px;right:0;inline-size:min(22rem,calc(100vw - 24px));max-block-size:min(78vh,48rem);overflow:auto;padding:10px;border:1px solid #cbd5e1;border-radius:14px;background:#fffc;color:#0f172a;box-shadow:0 16px 40px #00000026;backdrop-filter:blur(14px)}h2,h3,p{margin:0}h2,h3{font-size:12px}p{margin-top:4px;color:#475569}.tools{display:flex;gap:6px;margin-top:8px}.tools button{padding:5px 7px;border-radius:8px;background:#e2e8f0;color:#0f172a;cursor:pointer}.group{margin-top:10px;padding-top:2px}.group h3{color:#475569;text-transform:uppercase;letter-spacing:.04em}.defs{display:grid;gap:6px;margin:6px 0 0}div.r{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 8px;align-items:center;padding-top:6px;border-top:1px solid #e2e8f0}dt,dd{min-inline-size:0;margin:0}code{color:#475569}.tone{display:grid;gap:6px}.tone-row{display:grid;grid-template-columns:2.25rem minmax(0,1fr);gap:6px;align-items:center}input{inline-size:100%;font:inherit}input[type='color']{inline-size:2.25rem;block-size:2rem;padding:0;border:1px solid #cbd5e1;border-radius:8px;background:#fff}input[type='text']{padding:6px 7px;border:1px solid #cbd5e1;border-radius:8px;background:#fff}input[type='range']{accent-color:#2563eb}input:focus-visible,.tools button:focus-visible,summary:focus-visible{outline:2px solid #2563eb;outline-offset:2px}@media(max-width:40rem){details{display:grid;justify-items:end}.panel{position:fixed;right:8px;bottom:50px;left:8px;top:auto;inline-size:auto;max-block-size:min(70dvh,32rem)}}` const panel = (name = defaults.panelName, defs = dmStyle.defs, opts = {}) => { const signal = opts.signal || defaults.signal, open = opts.open || defaults.open, help = opts.help || defaults.help, sigJs = pathJs(signal), helpAttr = help ? ` data-m-ex:.title@${help}` : '', parts = partitionDefs(defs) dmStyle.panels[name] = defs const range = (d, path = signal + '.' + kebab(d.key)) => `
${d.css}
` const tone = (d, path = signal + '.' + kebab(d.key), colorLabel = d.label + ' color') => `
${d.css}
` - return dmWc(name, `

${opts.title || 'Style props'}

${opts.note || 'Compact live tokens for this page. Copy styles writes the current values as a :root block.'}

Layout

${parts.range.map((d) => range(d)).join('')}

Tones

${parts.tone.map((d) => tone(d)).join('')}
`) + // ^dom.open isolates the panel's CSS via shadow root, and :_wc keeps the + // open state on the host instead of polluting the page-level signal store. + return dmWc(name, `

${opts.title || 'Style props'}

${opts.note || 'Compact live tokens for this page. Copy styles writes the current values as a :root block.'}

Layout

${parts.range.map((d) => range(d)).join('')}

Tones

${parts.tone.map((d) => tone(d)).join('')}
`, undefined, [{ root: 'dom', path: ['open'] }]) } const findPanelEl = (root, name) => root && root.querySelector(name) const pinExpr = (panelName) => `dmStyle.applyVars(el._dmStyleRoot,dmStyle.reconcileVars(el._dmStyleRoot,val,dmStyle.panels['${panelName}']))` diff --git a/dmax.js b/dmax.js index 025fe3c..c58bdf3 100644 --- a/dmax.js +++ b/dmax.js @@ -25,7 +25,9 @@ const SEL_LEADS = '#.[*:', SSE_COMMENT = ':' const SI = 's', EP = DOT, SP = '_' - const M_WITH_SHAPE = 'with_shape', M_SHAPE_ONLY = 'shape_only' + const M_WITH_SHAPE = 'with_shape', M_SHAPE_ONLY = 'shape_only', M_DOM = 'dom' + const M_DOM_OPEN = 'open', M_DOM_CLOSED = 'closed' + const WC_HOST_ROOT = '_wc' const M_IMMEDIATE = 'immediate', M_NOT_IMMEDIATE = 'notImmediate' const M_ONCE = 'once', M_ALWAYS = 'always', M_DEBOUNCE = 'debounce', M_THROTTLE = 'throttle', M_RAF = 'raf', M_PREVENT = 'prevent' const M_AND = 'and', M_EQ = 'eq', M_NE = 'ne', M_LT = 'lt', M_GT = 'gt', M_LE = 'le', M_GE = 'ge', M_UNIV = '', M_CONST = 'const', M_NULL = 'null', M_TRUE = 'true', M_FALSE = 'false', M_UNDEFINED = 'undefined', M_EL = 'el', M_ATTRS = 'attrs', M_SEL = 'sel', M_SEL_ALL = 'selAll', M_SI = 'si', M_EV = 'ev', M_RW = 'rw', M_NUM = 'num', M_STR = 'str', M_BOOL = 'bool', M_JSOS = 'jsos' @@ -70,10 +72,6 @@ const E_RW_EV = `dmEx ${MOD}${M_RW} event is not found in trigger:` const E_TRIG_EL = 'Element is not found in trigger:', E_TRIG_EV = 'Event is not found in trigger:', E_FORM_EL = 'Form element is not found for trigger:' const IT_STATES = new WeakMap(), IT_ATTRS = new WeakMap() - const WC_ROOT = '_wc' - const _wcSubs = new WeakMap() // per-host subscriptions for :_wc signals - const getWcHost = (el) => { let n = el; while (n) { if (n._wc !== undefined || (n.tagName && n.tagName.indexOf('-') >= 0)) return n; n = n.parentNode && n.parentNode.nodeType === 11 ? n.parentNode.host : n.parentNode } return el } - const getWcStore = (host) => host._wc || (host._wc = Object.create(null)) const isSp = (n) => { if (n.startsWith(SP)) for (const s of SPS) if (n.startsWith(s, 1)) return true; return false } const mkIt = (kind, not, root, path, mods = NIL) => ({ kind, not, root, path, mods, sp: kind === SP ? SP_DEFS[root] || null : null, isSi: kind === SI, isEv: kind === EP, isSp: kind === SP, isImmediate: null }) const mkMod = (not, root, path) => ({ not, root, path, isImmediate: root === M_IMMEDIATE ? true : root === M_NOT_IMMEDIATE ? false : null }) @@ -106,7 +104,7 @@ d = indexFirst(n, NAME_DELIMS, p) const part = n.slice(partStart, p = d < 0 ? l : d) if (!part) { logErr('empty path part:', n, dKey); return null } - path.push(toName(part, 1)) + path.push(part[0] === '-' ? part : toName(part, 1)) } else if (c === BRACKET_OPEN) { d = n.indexOf(BRACKET_CLOSE, p + 1) if (d < 0) { logErr('missing ]:', n, dKey); return null } @@ -203,6 +201,61 @@ } const _dm = new Map() + const _wcSubs = new WeakMap() + const isWcRoot = (root) => root === WC_HOST_ROOT + // Walk up the DOM (including shadow host) to find the nearest ancestor + // whose _wc has been initialized. Falls back to lazily creating one + // on the binding's element when no scope owner is found. + const resolveWcHost = (el) => { + if (!el) return null + let cur = el + while (cur) { + if (cur._wc && typeof cur._wc === 'object') return cur + let parent = cur.parentNode + if (!parent && cur.host) parent = cur.host + cur = parent + } + return el + } + // Collect all signal subscriptions that are scoped to any element + // between (and including) the binding element and an ancestor with _wc. + // A binding registered on `el` lives in _wcSubs.get(el), and we walk + // up to ancestor hosts to notify on writes. We also walk DOWN the host's + // subtree to catch bindings that were registered before the host's _wc + // was initialized (so they ended up keyed on themselves). + const getWcSubsFor = (host) => { + const out = [] + const seen = new Set() + let cur = host + while (cur) { + const subs = _wcSubs.get(cur) + if (subs && subs.length) for (const s of subs) if (!seen.has(s)) { seen.add(s); out.push(s) } + let parent = cur.parentNode + if (!parent && cur.host) parent = cur.host + cur = parent + } + const walkDescendants = (root) => { + const stack = [root.firstElementChild] + while (stack.length) { + const el = stack.pop() + if (!el) continue + const subs = _wcSubs.get(el) + if (subs && subs.length) for (const s of subs) if (!seen.has(s)) { seen.add(s); out.push(s) } + // Skip into shadow roots + if (el.shadowRoot) for (let ch = el.shadowRoot.firstElementChild; ch; ch = ch.nextElementSibling) stack.push(ch) + for (let ch = el.lastElementChild; ch; ch = ch.previousElementSibling) stack.push(ch) + } + } + walkDescendants(host) + return out + } + const getHostStore = (host) => { + const h = resolveWcHost(host) + if (!h) return null + if (!h._wc || typeof h._wc !== 'object') h._wc = noProto() + return h._wc + } + const getHostEl = (host) => resolveWcHost(host) const DM = new Proxy({}, { get: (_, key) => _dm.get(key), set: (_, key, val) => { _dm.set(key, val); return true; }, @@ -216,7 +269,32 @@ // - data-m-si:foo='{bar: "hey"}' // foo signal // - data-m-si:foo:baz='`js expr ${42}`' // eval expr as Function body and set to all signals // - data-m-si:foo='el.Value * dm.bar' // you may use other signals and element props - // - data-m-si:_wc='{"count": 0}' // per-host state + const setSiRaw = (root, path, val, host) => { + if (isWcRoot(root)) { + if (!host) return null + if (!path || !path.length) { + host._wc = val && typeof val === 'object' ? val : noProto() + return host._wc + } + if (!host._wc || typeof host._wc !== 'object') host._wc = noProto() + let parent = host._wc + for (let i = 0; i < path.length - 1; ++i) { + parent = parent[path[i]] && typeof parent[path[i]] === 'object' ? parent[path[i]] : (parent[path[i]] = noProto()) + } + parent[path.at(-1)] = val + return host._wc + } + if (!path || !path.length) { _dm.set(root, val); return val } + let cur = _dm.get(root) + if (!cur || typeof cur !== 'object') _dm.set(root, cur = noProto()) + let parent = cur + for (let i = 0; i < path.length - 1; ++i) { + parent = parent[path[i]] && typeof parent[path[i]] === 'object' ? parent[path[i]] : (parent[path[i]] = noProto()) + } + parent[path.at(-1)] = val + return cur + } + const dmSi = (el, dKey, dVal) => { const it = parseCached(dKey), tars = it[TARG] if (it[MOD].length || it[TRIG].length || it[ADD].length) warn('targets only:', dKey) @@ -225,17 +303,14 @@ let val = dVal ? fn(DM, el, null) : null if (!tars.length) { if (!(val && typeof val === 'object')) return logErr('object value expected:', dKey, dVal) - for (const t in val) _dm.set(toName(t, 1), val[t]) + for (const t in val) setSiAndNotifySubsNDeep(dKey, mkIt(SI, null, toName(t, 1), null), val[t], el) return } for (const t of tars) { if (t.kind != SI) { logErr('signal targets only:', t, dKey); continue } if (t.mods.length) warn('mods ignored:', t.mods, dKey) - if (t.root === WC_ROOT) { - const store = getWcStore(el) - if (val && typeof val === 'object') Object.assign(store, val) - else el._wc = val - } else _dm.set(t.root, val) + if (isWcRoot(t.root)) { if (!el) { logErr('dmSi :_wc needs a host element:', dKey); continue } setSiRaw(t.root, t.path, val, el) } + else _dm.set(t.root, val) } } @@ -436,31 +511,25 @@ const PERMIT_MODS = Object.assign(noProto(), { [M_AND]: 1, [M_EQ]: 1, [M_NE]: 1, [M_LT]: 1, [M_GT]: 1, [M_LE]: 1, [M_GE]: 1 }) - const getSiVal = (it, el) => { - const root = it.root - if (root === WC_ROOT) { - const host = el ? getWcHost(el) : null - const sig = host ? host._wc : undefined - return it.path ? getPrValAndDepth(sig, it.path)[0] : sig - } - const sig = _dm.get(root) + const getSiVal = (it, host) => { + const sig = isWcRoot(it.root) ? getHostStore(host) : _dm.get(it.root) const path = it.path return path ? getPrValAndDepth(sig, path)[0] : sig } - const getSiValOrIt = (it) => { + const getSiValOrIt = (it, host) => { if (!it.kind) return it - const val = getSiVal(it) + const val = getSiVal(it, host) return it.not ? !val : val } - const resolveMPathVal = (v) => { - if (v && v.kind) return getSiValOrIt(v) + const resolveMPathVal = (v, host) => { + if (v && v.kind) return getSiValOrIt(v, host) if (typeof v !== 'string') return v - if (_dm.has(v)) return _dm.get(v) + if (!isWcRoot(v) && _dm.has(v)) return _dm.get(v) const parsed = parseRef('mod', v) if (!parsed || !parsed.kind) return v - if (parsed.isSi && !parsed.path && !_dm.has(parsed.root)) return v - return getSiValOrIt(parsed) + if (parsed.isSi && !parsed.path && !isWcRoot(parsed.root) && !_dm.has(parsed.root)) return v + return getSiValOrIt(parsed, host) } const dmJsos = (v, sp = 2) => typeof v === 'string' ? v : JSON.stringify(v, null, +(resolveMPathVal(sp) ?? 2) || 0) @@ -590,7 +659,7 @@ const stack = [node] while (stack.length) { const el = stack.pop() - if (noScan(el)) continue + if (!el || noScan(el)) continue const itAttrs = IT_ATTRS.get(el) if (itAttrs && itAttrs.length) { for (let i = 0; i < itAttrs.length; ++i) globalThis.wireNode(el, itAttrs[i][0], itAttrs[i][1]) @@ -602,7 +671,7 @@ } } const children = el.children - if (children) for (let i = children.length - 1; i >= 0; --i) stack.push(children[i]) + for (let i = children.length - 1; i >= 0; --i) stack.push(children[i]) } } const expected = (v, ...msg) => v || (warn(...msg), null) @@ -670,10 +739,10 @@ } } - const applyActPayload = (dKey, resultTa, payload, resultMode) => { + const applyActPayload = (dKey, resultTa, payload, resultMode, host) => { if (!resultTa) return - const prev = getSiValOrIt(resultTa) - setSiAndNotifySubsNDeep(dKey, resultTa, combineActResult(prev, payload, resultMode)) + const prev = getSiValOrIt(resultTa, host) + setSiAndNotifySubsNDeep(dKey, resultTa, combineActResult(prev, payload, resultMode), host) } const permitVal = (m, val, n = m.root, v = resolveMPathVal(m.path)) => n === M_AND ? !!v != !!m.not : n == M_EQ ? val == v : n == M_NE ? val != v : n == M_GT ? +val > +v : n == M_LT ? +val < +v : n == M_GE ? +val >= +v : +val <= +v @@ -688,14 +757,14 @@ return list } const removeSiSub = (sub) => { - if (sub.trig.root === WC_ROOT) { - const host = getWcHost(sub.el) - const subs = _wcSubs.get(host) + const root = sub.trig.root + if (isWcRoot(root)) { + const subs = sub.wcHost && _wcSubs.get(sub.wcHost) if (!subs || !subs.length) return for (let i = 0; i < subs.length; ++i) if (subs[i] === sub) { subs.splice(i, 1); return } return } - const subs = _subs.get(sub.trig.root) + const subs = _subs.get(root) if (!subs || !subs.length) return for (let i = 0; i < subs.length; ++i) if (subs[i] === sub) { subs.splice(i, 1); return } } @@ -717,8 +786,8 @@ } const PASSIVE_LISTENER_OPTS = Object.freeze({ passive: true }) const ELEMENT_NODE = 1 - const invokeSub = (fn, detail, trVal, el, tr) => fn(DM, el, tr, tr.isSi ? getSiVal(tr, el) : trVal, detail) - const invokeBoundSub = (sub, detail = null) => sub.fn(DM, sub.el, sub.trig, sub.trig.isSi ? getSiVal(sub.trig, sub.el) : null, detail) + const invokeSub = (fn, detail, trVal, el, tr, host) => fn(DM, el, tr, tr.isSi ? getSiVal(tr, host || el) : trVal, detail) + const invokeBoundSub = (sub, detail = null, host) => sub.fn(DM, sub.el, sub.trig, sub.trig.isSi ? getSiVal(sub.trig, host || sub.el) : null, detail) const onIntervalSub = (sub) => { const detail = { tick: sub.tick++, ms: sub.ms, type: SP_INTERVAL } try { invokeSub(sub.fn, detail, sub.ms, sub.el, sub.trig) } @@ -745,8 +814,33 @@ if (typeof CSS !== 'undefined' && CSS?.escape) return CSS.escape(s) return s.replace(/["\\]/g, '\\$&') } - const dmSel = (sel, root = document) => root.querySelector(sel || '') - const dmSelAll = (sel, root = document) => Array.from(root.querySelectorAll(sel || '')) + const dmSel = (sel, root = document) => { + const found = root.querySelector(sel || '') + if (found) return found + const all = root.querySelectorAll('*') + for (let i = 0; i < all.length; ++i) if (all[i].shadowRoot) { + const deep = dmSel(sel, all[i].shadowRoot) + if (deep) return deep + } + return null + } + const splitCompound = (sel) => { + const s = sel || '', i = s.indexOf(' ') + return i < 0 ? [s, null] : [s.slice(0, i), s.slice(i + 1)] + } + const dmSelAll = (sel, root = document) => { + const out = Array.from(root.querySelectorAll(sel || '')) + const [head, tail] = splitCompound(sel) + const all = root.querySelectorAll('*') + for (let i = 0; i < all.length; ++i) { + const el = all[i] + if (el.shadowRoot) { + if (tail && el.matches && el.matches(head)) out.push(...dmSelAll(tail, el.shadowRoot)) + else if (!tail) out.push(...dmSelAll(sel, el.shadowRoot)) + } + } + return out + } const dmEl = (id, root = document) => { if (!id) return null const rid = String(id)[0] === '#' ? String(id).slice(1) : String(id) @@ -811,12 +905,12 @@ if (tr.isSi) { const sub = { el, trig: tr, fn, siChangeM: mod.c, ev: null, clearId: null } sub.fn = applyTrMs(fn, tr, mod, sub, el) - if (tr.root === WC_ROOT) { - const host = getWcHost(el) - upsert(_wcSubs, host).push(sub) - } else { - upsert(_subs, tr.root).push(sub) + if (isWcRoot(tr.root)) { + const actualHost = resolveWcHost(el) || el + sub.wcHost = actualHost + upsert(_wcSubs, actualHost).push(sub) } + else upsert(_subs, tr.root).push(sub) ;(elSubs || upsert(_cleanupBoundSubs, el)).push(sub) return sub } @@ -847,54 +941,26 @@ }) } - const setSiAndNotifySubs = (dKey, tar, val, wcEl) => { + const setSiAndNotifySubs = (dKey, tar, val, host) => { const root = tar?.root, path = tar?.path if (!root) return null - - // Per-host _wc signal - if (root === WC_ROOT) { - const host = wcEl ? getWcHost(wcEl) : null - if (!host) return null - const store = getWcStore(host) - let siVal = store, curVal = siVal, parent = siVal, d = 0, last = null - if (path) { - if (!path.length) return null - for (; d < path.length - 1; ++d) parent = parent[path[d]] && typeof parent[path[d]] === 'object' ? parent[path[d]] : (parent[path[d]] = {}) - curVal = parent[last = path[d]] - } - if (!valChangedDeep(curVal, val)) return - const handlers = _wcSubs.get(host) - if (!handlers) { - if (!path) host._wc = val - else parent[last] = val - return - } - let collected = [] - for (const h of handlers) { - const hp = h.trig.path - if (!hp) { collected.push([h, null]); continue } - if (path) { - const pl = path.length, hl = hp.length, minLen = hl < pl ? hl : pl - let i = 0 - for (; i < minLen && path[i] == hp[i]; ++i); - if (i < minLen) continue - if (hl < pl || pl == hl) { collected.push([h, null]); continue } - } - const pathCur = getPrValAndDepth(siVal, hp)[0] - const pathVal = getPrValAndDepth(val, hp)?.[0] ?? getPrValAndDepth(path ? val : store, hp)?.[0] - if (pathCur !== undefined && !valChangedDeep(pathCur, pathVal)) continue - collected.push([h, null]) - } - if (!path) host._wc = val - else parent[last] = val - for (const col of collected) invokeBoundSub(col[0], null) - return - } - - let siVal = _dm.get(root), curVal = siVal, parent = siVal, d = 0, last = null + const wc = isWcRoot(root) + let actualHost, siVal + if (wc) { + actualHost = getHostEl(host) + if (!actualHost) return null + if (!actualHost._wc || typeof actualHost._wc !== 'object') actualHost._wc = noProto() + siVal = actualHost._wc + } else { + siVal = _dm.get(root) + } + let curVal = siVal, parent = siVal, d = 0, last = null if (path) { if (!path.length) return null - if (!parent || typeof parent !== 'object') _dm.set(root, parent = siVal = {}) + if (!parent || typeof parent !== 'object') { + if (wc) actualHost._wc = siVal = parent = noProto() + else _dm.set(root, parent = siVal = {}) + } for (; d < path.length - 1; ++d) parent = parent[path[d]] && typeof parent[path[d]] === 'object' ? parent[path[d]] : (parent[path[d]] = {}) curVal = parent[last = path[d]] } @@ -902,9 +968,12 @@ // if change detected it means ALL parents of cur and SOME of children changed if (!valChangedDeep(curVal, val)) return; - const handlers = _subs.get(root); - if (!handlers) { - if (!path) _dm.set(root, val) + const handlers = wc ? getWcSubsFor(actualHost) : (() => { const h = _subs.get(root); return h && h.length ? h : NIL })(); + if (!handlers || !handlers.length) { + if (!path) { + if (wc) actualHost._wc = val && typeof val === 'object' ? val : noProto() + else _dm.set(root, val) + } else parent[last] = val return } @@ -948,21 +1017,24 @@ if (changeMod !== SI_CHANGED_SHAPE_ONLY || pathDiff) collected.push([h, pathDiff, pathVal]) } - if (!path) _dm.set(root, val) + if (!path) { + if (wc) actualHost._wc = val && typeof val === 'object' ? val : noProto() + else _dm.set(root, val) + } else parent[last] = val for (const col of collected) { // notify with new values and diff if asked for const h = col[0] - invokeBoundSub(h, h.siChangeM === SI_CHANGED_ANY ? null : col[1]) + invokeBoundSub(h, h.siChangeM === SI_CHANGED_ANY ? null : col[1], actualHost) } updateDebug() } let syncDepth=0,MAX_SYNC_DEPTH=32; - const setSiAndNotifySubsNDeep = (dKey, tar, val, wcEl) => { + const setSiAndNotifySubsNDeep = (dKey, tar, val, host) => { if (syncDepth++ > MAX_SYNC_DEPTH) return logErr(`Error: Infinite loop detected for signal: ${tar} (depth > ${MAX_SYNC_DEPTH}) in ${dKey}`) - try { return setSiAndNotifySubs(dKey, tar, val, wcEl) } finally { syncDepth-- } + try { return setSiAndNotifySubs(dKey, tar, val, host) } finally { syncDepth-- } } /** @@ -1230,6 +1302,7 @@ tar = parseCached(getApiDKey(tar, tar[0] === ':' ? 'si' : 'si:'))[TARG][0] if (tar?.kind !== SI) return logErr('dmSet signal target expected:', tar, dKey), null } + if (isWcRoot(tar.root)) return logErr('dmSet cannot target _wc (per-host) signal; use dmSetHost(host, path, val) in:', dKey), null setSiAndNotifySubsNDeep(dKey, tar, val) return val } @@ -1263,20 +1336,48 @@ globalThis.dmSet = dmSet globalThis.dmSub = dmSub globalThis.dmSel = dmSel, globalThis.dmSelAll = dmSelAll, globalThis.dmEl = dmEl - globalThis.dmGetHost = (host, path) => { - if (!host || !host._wc) return undefined - if (!path) return host._wc - const parts = typeof path === 'string' ? path.split('.') : path - return getPrValAndDepth(host._wc, parts)[0] - } - globalThis.dmSetHost = (host, path, val) => { - if (!host) return val - const store = getWcStore(host) - const parts = typeof path === 'string' ? path.split('.') : path - if (!parts || !parts.length) { host._wc = val; return val } - setSiAndNotifySubs('dmSetHost', { root: WC_ROOT, path: parts }, val, host) + // - dmGetHost(host, 'x') / dmSetHost(host, 'x', 1) + // Per-host (per-element) state under the special _wc signal root. + // Stored on host._wc. Writes notify bindings scoped to that host. + const parseWcPath = (path) => { + const s = String(path || '') + let root, parts + if (!s) { + root = WC_HOST_ROOT + parts = null + } else { + const cleaned = s.replace(/^_wc\.?/, '') + if (cleaned === s && !s.startsWith('.')) { + // bare path like "count" or "obj.deep" — implicit _wc root + root = WC_HOST_ROOT + parts = cleaned ? cleaned.split('.').map(p => /^\d+$/.test(p) ? p : toName(p, 1)) : null + } else { + const parsed = parseRef('dmHost', s) + if (!parsed || !parsed.isSi) return null + root = parsed.root === '_wc' || parsed.root === 'wc' ? WC_HOST_ROOT : parsed.root + if (root !== WC_HOST_ROOT) return null + parts = parsed.path || null + } + } + return { root, path: parts } + } + const dmGetHost = (host, path) => { + if (!host) return undefined + const p = parseWcPath(path) + if (!p) return undefined + const sig = host._wc + return p.path ? getPrValAndDepth(sig, p.path)[0] : sig + } + const dmSetHost = (host, path, val) => { + if (!host) return logErr('dmSetHost: host required'), null + const p = parseWcPath(path) + if (!p) return logErr('dmSetHost: path must target _wc.* signal, got:', path), null + const tar = mkIt(SI, null, p.root, p.path) + setSiAndNotifySubsNDeep('dmSetHost', tar, val, host) return val } + globalThis.dmGetHost = dmGetHost + globalThis.dmSetHost = dmSetHost // - data-m-it@posts // - data-m-it+#tpl-post@posts @@ -1380,13 +1481,13 @@ const queryParams = noProto(), bodyFields = noProto(), addDst = isGetOrDelete ? queryParams : bodyFields if (sendAll) for (const [siName, siVal] of _dm.entries()) bodyFields[siName] = siVal for (const add of adds) { - const val = add.isEv ? getElPrVal(add.taEl || el, add.path) : getSiValOrIt(add) + const val = add.isEv ? getElPrVal(add.taEl || el, add.path) : getSiValOrIt(add, el) if (add.spread) { if (val && typeof val === 'object') for (const k in val) if (hasOwn(val, k)) addDst[k] = val[k] else addDst.value = val } else addDst[add.key] = val } - for (const [isBody, key, path, ref] of actRouteMods) (isBody ? bodyFields : queryParams)[key] = ref ? getSiValOrIt(ref) : _dm.get(path) + for (const [isBody, key, path, ref] of actRouteMods) (isBody ? bodyFields : queryParams)[key] = ref ? getSiValOrIt(ref, el) : _dm.get(path) let finalUrl = url, hasQ = finalUrl.includes('?') for (const k in queryParams) finalUrl += (hasQ ? '&' : '?') + encodeURIComponent(k) + '=' + encodeURIComponent('' + (queryParams[k] ?? '')), hasQ = true let hs = ACT_HS_EMPTY, sharedHs = 1 @@ -1411,7 +1512,7 @@ } for (const [kebabKey, path, ref] of actHdrMods) { if (sharedHs) hs = cloneOwnProps(hs), sharedHs = 0 - const v = ref ? getSiValOrIt(ref) : _dm.get(path) + const v = ref ? getSiValOrIt(ref, el) : _dm.get(path) hs[kebabKey] = v != null ? '' + v : '' } let bodyCount = 0, firstBodyKey = null @@ -1451,7 +1552,7 @@ applyPatchEls({ [SSE_ELS]: payload, selector, mode }) } else { payload = isJsonContentType(ct) ? await res.json() : await res.text() - applyActPayload(dKey, resultTa, payload, resultMode) + applyActPayload(dKey, resultTa, payload, resultMode, el) if (patchAll) patchMatchingSis(dKey, payload, resultMode) } ss(M_BUSY, false), ss(M_COMPLETE, true), ss(M_ERR, null), ss(M_CODE, Number.isFinite(res.status) ? res.status : null), ss(M_ABORT, null) @@ -1497,70 +1598,75 @@ // - dmSet('go', 1) const dmActApi = (el, dKey, dVal) => bindAddedSubs(el, (host) => dmAct(host, getApiDKey(dKey, ''), dVal)) const WC_TMPLS = new WeakSet(), WC_INITS = new WeakSet(), WC_PROP_RE = /[^,\s]+/g - const WC_DOM_OPEN = 'dom.open', WC_DOM_CLOSED = 'dom.closed' - const projectSlots = (host, target) => { - const slots = target.querySelectorAll('slot') + const projectSlots = (host, contentFrag) => { + const slots = contentFrag.querySelectorAll('slot') if (!slots.length) return - const children = Array.from(host.childNodes) - for (const slot of Array.from(slots)) { - const name = slot.getAttribute('name') - let projected = false - for (let i = children.length - 1; i >= 0; --i) { - const ch = children[i] - if (name ? (ch.getAttribute && ch.getAttribute('slot') === name) : (ch.nodeType === 1 ? !ch.hasAttribute('slot') : ch.nodeType === 3)) { - slot.parentNode.insertBefore(ch, slot) - children.splice(i, 1) - projected = true - } + const hostChildren = [] + for (let i = host.children.length - 1; i >= 0; --i) hostChildren.push(host.children[i]) + const defaultSlot = [], namedSlots = noProto() + for (const ch of hostChildren) { + const slotName = ch.getAttribute && ch.getAttribute('slot') + if (slotName) (namedSlots[slotName] || (namedSlots[slotName] = [])).push(ch) + else defaultSlot.push(ch) + } + for (const slot of slots) { + const slotName = slot.getAttribute('name') || '' + const projected = slotName ? namedSlots[slotName] : defaultSlot + if (projected && projected.length) { + const parent = slot.parentNode + if (!parent) continue + for (const child of projected) parent.insertBefore(child, slot) + parent.removeChild(slot) } - if (projected) slot.parentNode.removeChild(slot) } } + const resolveWcMods = (mods) => { + let shadowMode = null + if (mods) for (const m of mods) { + if (m.root === M_DOM && m.path && m.path[0] === M_DOM_OPEN) shadowMode = 'open' + else if (m.root === M_DOM && m.path && m.path[0] === M_DOM_CLOSED) shadowMode = 'closed' + } + return shadowMode + } const defWc = (tpl, name, mods) => { if (!name || name.indexOf('-') < 0) return logErr('dmWc template expects custom-element name value:', name) if (customElements.get(name) || WC_TMPLS.has(tpl)) return tpl WC_TMPLS.add(tpl) - const useShadow = mods && (mods.has(WC_DOM_OPEN) || mods.has(WC_DOM_CLOSED)) - const shadowMode = useShadow ? (mods.has(WC_DOM_CLOSED) ? 'closed' : 'open') : null - const hasSlots = !shadowMode && tpl.content && tpl.content.querySelector('slot') const props = (tpl.getAttribute(DM_KEY + 'wc-props') || '').match(WC_PROP_RE) || NIL + const shadowMode = resolveWcMods(mods) const WC = class extends HTMLElement { connectedCallback() { - if (WC_INITS.has(this)) return; WC_INITS.add(this) - const target = shadowMode ? this.attachShadow({ mode: shadowMode }) : this - if ((shadowMode || hasSlots || !this.firstElementChild) && tpl.content) { - const clone = tpl.content.cloneNode(true) - if (hasSlots) { - const wrapper = document.createElement('div') - wrapper.appendChild(clone) - projectSlots(this, wrapper) - while (wrapper.firstChild) target.appendChild(wrapper.firstChild) - } else target.appendChild(clone) + if (WC_INITS.has(this)) return + WC_INITS.add(this) + let target + if (shadowMode) target = this.shadowRoot || this.attachShadow({ mode: shadowMode }) + else target = this + if (tpl.content) { + const frag = tpl.content.cloneNode(true) + if (!shadowMode) projectSlots(this, frag) + target.appendChild(frag) wireItClone(target) } - for (const p of props) { let v = this['$' + p]; if (hasOwn(this, p)) v = this[p], delete this[p]; v !== undefined && (this[p] = v) } + if (shadowMode) wireItClone(this) + for (const p of props) { + let v = this['$' + p] + if (hasOwn(this, p)) v = this[p], delete this[p] + v !== undefined && (this[p] = v) + } } } - for (const p of props) Object.defineProperty(WC.prototype, p, { get() { return this['$' + p] }, set(v) { this['$' + p] = v, this.dispatchEvent(new CustomEvent(p, { detail: v })); const root = this.shadowRoot || this; for (let ch = root.firstElementChild; ch; ch = ch.nextElementSibling) ch.dispatchEvent(new CustomEvent(p, { detail: v })) } }) + for (const p of props) Object.defineProperty(WC.prototype, p, { get() { return this['$' + p] }, set(v) { this['$' + p] = v, this.dispatchEvent(new CustomEvent(p, { detail: v, bubbles: true, composed: true })); for (let ch = this.firstElementChild; ch; ch = ch.nextElementSibling) ch.dispatchEvent(new CustomEvent(p, { detail: v, bubbles: true, composed: true })) } }) customElements.define(name, WC) return tpl } const toWcTpl = (html, props, tpl = document.createElement('template')) => (props != null && tpl.setAttribute(DM_KEY + 'wc-props', Array.isArray(props) ? props.join(' ') : '' + props), tpl.innerHTML = html, tpl) - const parseWcMods = (dKey) => { - const mods = new Set() - const rest = dKey.slice(DM_KEY.length) - let p = rest.indexOf('^') - while (p >= 0) { - const end = indexFirst(rest, ['^', ':', '@'], p + 1) - const mod = rest.slice(p + 1, end < 0 ? rest.length : end) - if (mod) mods.add(mod) - p = end < 0 ? -1 : rest[end] === '^' ? end : -1 - } - return mods.size ? mods : null - } // - dmWc('my-card','
'), dmWc(tplEl,'my-card') - const dmWc = (nameOrTpl, htmlOrName, props, mods) => nameOrTpl && nameOrTpl.tagName === 'TEMPLATE' ? defWc(nameOrTpl, htmlOrName && htmlOrName.trim(), mods) : typeof htmlOrName === 'string' ? defWc(typeof nameOrTpl === 'string' ? toWcTpl(htmlOrName, props) : nameOrTpl, nameOrTpl && nameOrTpl.trim(), mods) : logErr('dmWc expects (name, html[, props]) or (templateEl, name):', nameOrTpl) - // - - // - - const dmWcAttr = (el, dKey, dVal) => el.tagName === 'TEMPLATE' ? dmWc(el, dVal, null, parseWcMods(dKey)) : logErr('Error: dmWc is template-only; use data-m-ex for WC host props in:', dKey) + const dmWc = (nameOrTpl, htmlOrName, props, mods) => nameOrTpl && nameOrTpl.tagName === 'TEMPLATE' ? defWc(nameOrTpl, htmlOrName && htmlOrName.trim(), mods) : typeof htmlOrName === 'string' ? defWc(typeof nameOrTpl === 'string' ? toWcTpl(htmlOrName, props) : nameOrTpl, nameOrTpl && nameOrTpl.trim(), mods) : logErr('dmWc expects (name, html[, props, mods]) or (templateEl, name[, mods]):', nameOrTpl) + // - + // - + const dmWcAttr = (el, dKey, dVal) => { + if (el.tagName !== 'TEMPLATE') return logErr('Error: dmWc is template-only; use data-m-ex for WC host props in:', dKey) + const it = parseCached(dKey), mods = it[MOD] + return dmWc(el, dVal, undefined, mods) + } const dmNo = () => {} globalThis.dmAct = dmActApi globalThis.dmWc = dmWc diff --git a/package.json b/package.json index 89bd2a4..41ec5c8 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,11 @@ "test:naming": "node tests/dmax.naming.js", "test:notebook": "node tests/notebook.asserts.e2e.js", "test:wc": "node tests/dmWc.e2e.js", - "test:wc-v2": "node tests/dmWcV2.e2e.js", + "test:host-scope": "node tests/dmHostScope.e2e.js", "test:auto-scan": "node tests/dmax.autoscan.e2e.js", "test:m-ex-cel": "node tests/m-ex-cel.e2e.js", "test:style": "node tests/dmStyle.e2e.js", - "test": "npm run test:actions && npm run test:fuzz && npm run test:headless && npm run test:size && npm run test:naming && npm run test:notebook && npm run test:wc && npm run test:wc-v2 && npm run test:auto-scan && npm run test:m-ex-cel && npm run test:style", + "test": "npm run test:actions && npm run test:fuzz && npm run test:headless && npm run test:size && npm run test:naming && npm run test:notebook && npm run test:wc && npm run test:host-scope && npm run test:auto-scan && npm run test:m-ex-cel && npm run test:style", "serve": "npx http-server -c-1 -p 8080" }, "name": "dmax", diff --git a/tests/dmHostScope.e2e.js b/tests/dmHostScope.e2e.js new file mode 100644 index 0000000..a078927 --- /dev/null +++ b/tests/dmHostScope.e2e.js @@ -0,0 +1,291 @@ +// tests/dmHostScope.e2e.js +// E2E tests for shadow modes, implicit slots, and per-host :_wc signal scope. +// Mirrors the test plan from https://github.com/dadhi/dmax/issues/142 + +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const { JSDOM } = require('jsdom') + +function waitFor(conditionFn, timeout = 5000, interval = 20, label = '') { + const start = Date.now() + return new Promise((resolve, reject) => { + ;(function poll() { + try { + const value = conditionFn() + if (value) return resolve(value) + } catch (err) { return reject(new Error('waitFor[' + label + '] threw: ' + (err.message || err))) } + if (Date.now() - start > timeout) { + try { conditionFn() } catch (e) { return reject(new Error('waitFor[' + label + '] threw at timeout: ' + (e.message || e))) } + return reject(new Error('waitFor[' + label + '] timed out')) + } + setTimeout(poll, interval) + })() + }) +} + +async function tick() { + await new Promise(r => setTimeout(r, 0)) +} + +;(async () => { + const src = fs.readFileSync(path.join(process.cwd(), 'dmax.js'), 'utf8') + const dom = new JSDOM(``, { + runScripts: 'dangerously', + pretendToBeVisual: true + }) + const { window } = dom + const { document } = window + window.addEventListener('error', e => console.error('[window error]', e.message, e.error && e.error.stack)) + window.addEventListener('unhandledrejection', e => console.error('[unhandled rejection]', e.reason && e.reason.stack || e.reason)) + + await waitFor(() => typeof window.dmWc === 'function' && typeof window.dmSetHost === 'function', 5000, 20, 'init') + + // =========================================================================== + // 01. ^dom.open — host.shadowRoot exists, content is in shadow, input event works + // =========================================================================== + window.dmWc('hs-open', '

') + const hsOpenTpl = window.dmWc('hs-open-mods', '

', undefined, [{ root: 'dom', path: ['open'] }]) + assert(hsOpenTpl, 'dmWc returns template') + const hsOpen = document.createElement('hs-open-mods') + document.body.append(hsOpen) + await waitFor(() => hsOpen.shadowRoot && hsOpen.shadowRoot.querySelector('p'), 5000, 20, 'hsOpen shadow p') + assert.strictEqual(hsOpen.shadowRoot.mode, 'open', 'shadow mode is open') + assert(hsOpen.shadowRoot.querySelector('p'), 'cloned content is in shadow root') + window.dmSet('note', 'shadow-hi') + await waitFor(() => hsOpen.shadowRoot.querySelector('p').textContent === 'shadow-hi', 5000, 20, 'note set in shadow') + // Input event inside shadow should propagate (composed: true) and update dm + const input = hsOpen.shadowRoot.querySelector('input') + input.value = 'typed-in-shadow' + input.dispatchEvent(new window.Event('input', { bubbles: true, composed: true })) + await waitFor(() => hsOpen.shadowRoot.querySelector('p').textContent === 'typed-in-shadow', 5000, 20, 'typed in shadow') + document.body.removeChild(hsOpen) + + // =========================================================================== + // 02. ^dom.closed — host.shadowRoot is null from outside, bindings still work + // =========================================================================== + window.dmWc('hs-closed', '

', undefined, [{ root: 'dom', path: ['closed'] }]) + const hsClosed = document.createElement('hs-closed') + document.body.append(hsClosed) + await waitFor(() => hsClosed.outerHTML.indexOf('hs-closed') >= 0, 5000, 20, 'hsClosed in DOM') + assert.strictEqual(hsClosed.shadowRoot, null, 'shadowRoot is null from outside for closed shadow') + + // =========================================================================== + // 03. Default is light DOM — (no mod) has no shadow root + // =========================================================================== + window.dmWc('hs-light', '

') + const hsLight = document.createElement('hs-light') + document.body.append(hsLight) + await waitFor(() => hsLight.querySelector('p'), 5000, 20, 'hsLight p') + assert.strictEqual(hsLight.shadowRoot, null, 'no shadow root for default light DOM') + assert(hsLight.querySelector('p'), 'content is in light DOM') + window.dmSet('note', 'light-hi') + await waitFor(() => hsLight.querySelector('p').textContent === 'light-hi', 5000, 20, 'light-hi') + document.body.removeChild(hsLight) + + // =========================================================================== + // 04. Slots (implicit) — projected children land in slot position, fallback preserved + // =========================================================================== + window.dmWc('hs-slot', '

Card

fallback-titlefallback-default
') + + // Default slot only — span without slot attr goes into default slot + const hsSlot1 = document.createElement('hs-slot') + hsSlot1.innerHTML = 'd-content' + document.body.appendChild(hsSlot1) + await waitFor(() => hsSlot1.querySelector('article'), 5000, 20, 'hsSlot1 article') + assert.strictEqual(hsSlot1.querySelector('h1').textContent, 'Card', 'static part rendered') + assert.strictEqual(hsSlot1.querySelector('span.d').textContent, 'd-content', 'default slot projected') + // Named slot (title) keeps fallback because no child has slot="title" + assert.strictEqual(hsSlot1.querySelectorAll('slot').length, 1, 'named slot keeps fallback when no match') + const fbTitleEl = hsSlot1.querySelector('article > slot') + assert.strictEqual(fbTitleEl.textContent, 'fallback-title', 'named slot fallback preserved when no match') + + // Named slot matching — both named and default slot are filled + const hsSlot2 = document.createElement('hs-slot') + hsSlot2.innerHTML = 'Td' + document.body.appendChild(hsSlot2) + await waitFor(() => hsSlot2.querySelector('article'), 5000, 20, 'hsSlot2 article') + assert.strictEqual(hsSlot2.querySelectorAll('slot').length, 0, 'no slot elements remain when all are projected') + const children = hsSlot2.querySelectorAll('article > *') + assert.strictEqual(children[1].textContent, 'T', 'named slot projected') + assert.strictEqual(children[2].textContent, 'd', 'default slot projected alongside named') + document.body.removeChild(hsSlot1) + document.body.removeChild(hsSlot2) + + // Recursive slots: nested WC inside outer WC with slots + window.dmWc('hs-inner', '
inner-fallback
') + const hsOuterTpl = document.createElement('template') + hsOuterTpl.setAttribute('data-m-wc', 'hs-outer') + hsOuterTpl.innerHTML = '
inner-content
' + window.dmWc(hsOuterTpl, 'hs-outer') + const hsOuter = document.createElement('hs-outer') + document.body.appendChild(hsOuter) + await waitFor(() => hsOuter.querySelector('hs-inner'), 5000, 20, 'hsOuter hs-inner') + await waitFor(() => hsOuter.querySelector('hs-inner').querySelector('span'), 5000, 20, 'recursive slot') + assert.strictEqual(hsOuter.querySelector('hs-inner').querySelector('span').textContent, 'inner-content', 'recursive slot projection') + document.body.removeChild(hsOuter) + + // =========================================================================== + // 05. :_wc on a WC — each instance has its own _wc + // =========================================================================== + window.dmWc('hs-counter', '') + const c1 = document.createElement('hs-counter') + c1.setAttribute('data-m-si:_wc.count', '0') + const c2 = document.createElement('hs-counter') + c2.setAttribute('data-m-si:_wc.count', '10') + document.body.append(c1) + document.body.append(c2) + await waitFor(() => c1.querySelector('span') && c2.querySelector('span'), 5000, 20, 'c1 c2 spans') + assert.strictEqual(c1.querySelector('span').textContent, '0', 'c1 starts at 0') + assert.strictEqual(c2.querySelector('span').textContent, '10', 'c2 starts at 10') + // Click c1 twice + c1.querySelector('button').click() + c1.querySelector('button').click() + await waitFor(() => c1.querySelector('span').textContent === '2', 5000, 20, 'c1 incremented') + assert.strictEqual(c1.querySelector('span').textContent, '2', 'c1 incremented') + assert.strictEqual(c2.querySelector('span').textContent, '10', 'c2 unaffected by c1 click') + assert.strictEqual(window.dmGetHost(c1, 'count'), 2, 'dmGetHost reads c1._wc.count') + assert.strictEqual(window.dmGetHost(c2, 'count'), 10, 'dmGetHost reads c2._wc.count') + assert.notStrictEqual(c1._wc, c2._wc, 'c1 and c2 have distinct _wc objects') + document.body.removeChild(c1) + document.body.removeChild(c2) + + // =========================================================================== + // 06. :_wc on a regular element — host resolution via ancestor lookup + // =========================================================================== + // The first div initializes its _wc via data-m-si. + // The second div reads/writes the FIRST div's _wc via ancestor walk-up. + const reg1 = document.createElement('div') + reg1.innerHTML = '' + reg1.setAttribute('data-m-si:_wc', JSON.stringify({ x: 1 })) + document.body.append(reg1) + window.dmScan(reg1) + await waitFor(() => reg1.querySelector('span').textContent === '1', 5000, 20, 'reg1 initial') + const reg1Btn = reg1.querySelector('button') + reg1Btn.click() + reg1Btn.click() + reg1Btn.click() + await waitFor(() => reg1.querySelector('span').textContent === '4', 5000, 20, 'reg1 incremented') + assert.strictEqual(reg1.querySelector('span').textContent, '4', 'reg1._wc.x incremented 3 times') + assert.strictEqual(window.dm.x, undefined, 'dm.x is not set') + assert.strictEqual(window.dmGetHost(reg1, 'x'), 4, 'dmGetHost reads reg1._wc.x') + document.body.removeChild(reg1) + + // =========================================================================== + // 07. :_wc on a list item — each li has its own _wc + // =========================================================================== + window.dmSet('items', [{ id: 'a' }, { id: 'b' }, { id: 'c' }]) + // Build the ul via innerHTML so we can include @ in the attribute name + const ulContainer = document.createElement('div') + ulContainer.innerHTML = '' + const ul = ulContainer.querySelector('ul') + document.body.appendChild(ul) + window.dmScan(ul) + await waitFor(() => ul.querySelectorAll('li').length === 3, 5000, 20, '3 li') + const lis = ul.querySelectorAll('li') + assert.strictEqual(lis.length, 3, 'three li rendered') + lis[1].dispatchEvent(new window.Event('mouseenter')) + await waitFor(() => lis[1]._wc && lis[1]._wc.hovered === true, 5000, 20, 'li[1] hovered') + assert.strictEqual(lis[1]._wc.hovered, true, 'second li._wc.hovered is true') + assert.strictEqual(lis[0]._wc, undefined, 'first li._wc not touched') + assert.strictEqual(lis[2]._wc, undefined, 'third li._wc not touched') + document.body.removeChild(ul) + window.dmSet('items', null) + + // =========================================================================== + // 08. Composition with ^merge — host._wc.todos, not dm.todos + // =========================================================================== + const comp = document.createElement('div') + comp.setAttribute('data-m-si:_wc', JSON.stringify({ todos: [{ id: 1, done: false }] })) + comp.innerHTML = '' + document.body.appendChild(comp) + window.dmScan(comp) + await tick() + const compBtn = comp.querySelector('button') + compBtn.click() + await waitFor(() => { + const arr = window.dmGetHost(comp, 'todos') + return arr && arr.length === 2 && arr[1].id === 2 && arr[1].done === true + }, 5000, 20, '_wc.todos merged') + assert.strictEqual(window.dm.todos, undefined, 'dm.todos is not set') + assert.strictEqual(window.dmGetHost(comp, 'todos').length, 2, '_wc.todos merged') + + // =========================================================================== + // 09. Composition with ^rw — two-way bind on host._wc.draft + // =========================================================================== + const rwEl = document.createElement('div') + rwEl.setAttribute('data-m-si:_wc.draft', '"hello"') + rwEl.innerHTML = '' + document.body.appendChild(rwEl) + window.dmScan(rwEl) + await tick() + const rwRead = rwEl.querySelector('span') + const rwInput = rwEl.querySelector('input') + assert.strictEqual(rwRead.textContent, 'hello', 'initial draft rendered') + rwInput.value = 'world' + rwInput.dispatchEvent(new window.Event('change', { bubbles: true })) + await waitFor(() => rwRead.textContent === 'world', 5000, 20, '^rw draft world') + assert.strictEqual(window.dmGetHost(rwEl, 'draft'), 'world', '_wc.draft is updated via ^rw') + assert.strictEqual(window.dm.draft, undefined, 'dm.draft is not set') + + // =========================================================================== + // 10. Cleanup — host.remove() tears down _wc subs + // =========================================================================== + const c10 = document.createElement('div') + c10.setAttribute('data-m-si:_wc.x', '0') + c10.innerHTML = '' + document.body.appendChild(c10) + window.dmScan(c10) + await tick() + const c10Btn = c10.querySelector('button') + c10Btn.click() + c10Btn.click() + await waitFor(() => window.dmGetHost(c10, 'x') === 2, 5000, 20, 'c10 _wc.x === 2') + const subsBefore = window.dmGetHost(c10, 'x') + assert.strictEqual(subsBefore, 2, 'before cleanup _wc.x is 2') + document.body.removeChild(c10) + await tick() + + // =========================================================================== + // 11. Public helpers — dmGetHost / dmSetHost trigger re-render + // =========================================================================== + const c11 = document.createElement('div') + c11.innerHTML = '' + document.body.appendChild(c11) + window.dmScan(c11) + await tick() + const c11Read = c11.querySelector('span') + assert.strictEqual(c11Read.textContent, '', 'empty initial') + window.dmSetHost(c11, 'label', 'hello-host') + await waitFor(() => c11Read.textContent === 'hello-host', 5000, 20, 'c11 label set') + assert.strictEqual(window.dmGetHost(c11, 'label'), 'hello-host', 'round-trip via dmGetHost/dmSetHost') + window.dmSetHost(c11, 'obj.deep', 42) + await waitFor(() => window.dmGetHost(c11, 'obj.deep') === 42, 5000, 20, 'nested path') + assert.strictEqual(window.dmGetHost(c11, 'obj.deep'), 42, 'nested path') + + document.body.removeChild(comp) + document.body.removeChild(rwEl) + document.body.removeChild(c11) + + // =========================================================================== + // 12. CSS custom properties — :style.--name (explicit) sets the --custom prop + // =========================================================================== + const c12 = document.createElement('div') + c12.setAttribute('data-m-si', '{"gap":1,"accent":"red"}') + c12.innerHTML = '
' + document.body.appendChild(c12) + window.dmScan(c12) + await tick() + const cssDivs = c12.querySelectorAll('div') + assert.strictEqual(cssDivs[0].style.getPropertyValue('--gap'), '1', ':style.--gap writes the explicit --gap custom property') + assert.strictEqual(cssDivs[1].style.getPropertyValue('--gap-2'), '2', ':style.--gap-2 writes a custom property with an internal dash') + assert.strictEqual(cssDivs[2].style.getPropertyValue('--accent-color'), 'red', ':style.--accent-color preserves internal dashes') + // The explicit -- syntax must NOT have leaked into a '---gap' typo + assert.strictEqual(cssDivs[0].style.getPropertyValue('---gap'), '', 'no ---gap triple-dash typo') + document.body.removeChild(c12) + + console.log('dmHostScope tests passed (shadow modes, slots, :_wc, css custom props)') +})().catch((err) => { + console.error(err && err.stack ? err.stack : err) + process.exit(1) +}) diff --git a/tests/dmStyle.e2e.js b/tests/dmStyle.e2e.js index 3a1c9ae..5ee655a 100644 --- a/tests/dmStyle.e2e.js +++ b/tests/dmStyle.e2e.js @@ -26,10 +26,20 @@ function waitFor(conditionFn, timeout = 5000, interval = 20) { const { document } = window window.dispatchEvent(new window.Event('load')) - await waitFor(() => window.dmStyle && document.querySelector('dm-style-panel input[type=color]')) + const findInPanel = (sel) => { + const p = document.querySelector('dm-style-panel') + if (!p) return null + return p.shadowRoot ? p.shadowRoot.querySelector(sel) : p.querySelector(sel) + } + const findAllInPanel = (sel) => { + const p = document.querySelector('dm-style-panel') + if (!p) return [] + return Array.from(p.shadowRoot ? p.shadowRoot.querySelectorAll(sel) : p.querySelectorAll(sel)) + } + await waitFor(() => window.dmStyle && findInPanel('input[type=color]')) assert.strictEqual(typeof window.dmStyle.exportCss, 'function', 'dmStyle export helper exists') assert.strictEqual(typeof window.dmStyle.importVals, 'function', 'dmStyle import helper exists') - assert.strictEqual(document.querySelectorAll('dm-style-panel input[type=color]').length >= 5, true, 'style panel renders tone color pickers') + assert.strictEqual(findAllInPanel('input[type=color]').length >= 5, true, 'style panel renders tone color pickers') assert.notStrictEqual(window.dmStyle.initVals(), window.dmStyle.initVals(), 'initVals returns a fresh object each time') const cssImport = window.dmStyle.importVals({ keep: 1, radius3: 1 }, ':root { --radius-3: 0rem; --tone-bg: oklch(96% .01 240); }') @@ -46,14 +56,14 @@ function waitFor(conditionFn, timeout = 5000, interval = 20) { const sameVars = window.dmStyle.reconcileVars(root, window.dm.style, window.dmStyle.defs) assert.strictEqual(sameVars, root._dmStyleVars, 'reconcileVars reuses cached vars when unchanged') - const radius = [...document.querySelectorAll('dm-style-panel input[type=range]')].find((i) => i.getAttribute('aria-label') === 'Radius 3') + const radius = [...findAllInPanel('input[type=range]')].find((i) => i.getAttribute('aria-label') === 'Radius 3') radius.value = '0' radius.dispatchEvent(new window.Event('input', { bubbles: true })) await waitFor(() => window.dm.style.radius3 === 0) assert.strictEqual(root.style.getPropertyValue('--radius-3'), '0rem', 'range writes mapped css vars') - const toneTxt = document.querySelector('dm-style-panel input[type=text][aria-label="Tone bg"]') - const toneColor = document.querySelector('dm-style-panel input[type=color][aria-label="Tone bg color"]') + const toneTxt = findInPanel('input[type=text][aria-label="Tone bg"]') + const toneColor = findInPanel('input[type=color][aria-label="Tone bg color"]') const prevToneBg = window.dm.style.toneBg toneColor.value = '#112233' toneColor.dispatchEvent(new window.Event('input', { bubbles: true })) @@ -64,7 +74,7 @@ function waitFor(conditionFn, timeout = 5000, interval = 20) { await waitFor(() => window.dm.style.toneBg === 'oklch(95% .02 200)') assert.strictEqual(toneColor.title, 'oklch help text', 'shared help tooltip bound to panel inputs') - const copyBtn = [...document.querySelectorAll('dm-style-panel button')].find((b) => b.textContent === 'copy styles') + const copyBtn = [...findAllInPanel('button')].find((b) => b.textContent === 'copy styles') copyBtn.click() await waitFor(() => window.navigator.clipboard.last.includes('--tone-bg')) assert(window.navigator.clipboard.last.includes(':root {'), 'copy exports css block') @@ -72,7 +82,7 @@ function waitFor(conditionFn, timeout = 5000, interval = 20) { const rejectedCopy = window.dmStyle.copy(window.dm.style, window.dmStyle.defs) assert(rejectedCopy.includes('--tone-bg'), 'copy still returns css when clipboard write rejects') - const importBtn = [...document.querySelectorAll('dm-style-panel button')].find((b) => b.textContent === 'import') + const importBtn = [...findAllInPanel('button')].find((b) => b.textContent === 'import') importBtn.click() await waitFor(() => window.dm.style.radius3 === 0 && window.dm.style.toneBg === 'oklch(96% .01 240)') @@ -86,9 +96,19 @@ function waitFor(conditionFn, timeout = 5000, interval = 20) { const host = wrap.firstElementChild document.body.appendChild(host) window.dmScan(host) - await waitFor(() => host.querySelector('dm-style-mini input[type=range]')) - const miniRange = host.querySelector('dm-style-mini input[type=range][aria-label="Gap 1"]') - const miniTone = host.querySelector('dm-style-mini input[type=text][aria-label="Tone alt"]') + await waitFor(() => { + const m = host.querySelector('dm-style-mini') + if (!m) return null + const root = m.shadowRoot || m + return root.querySelector('input[type=range]') + }) + const findInMini = (sel) => { + const m = host.querySelector('dm-style-mini') + if (!m) return null + return m.shadowRoot ? m.shadowRoot.querySelector(sel) : m.querySelector(sel) + } + const miniRange = findInMini('input[type=range][aria-label="Gap 1"]') + const miniTone = findInMini('input[type=text][aria-label="Tone alt"]') miniRange.value = '2' miniRange.dispatchEvent(new window.Event('input', { bubbles: true })) miniTone.value = 'oklch(88% .03 180)' @@ -96,21 +116,22 @@ function waitFor(conditionFn, timeout = 5000, interval = 20) { await waitFor(() => window.dm.mini.gap1 === 2 && window.dm.mini.toneAlt === 'oklch(88% .03 180)') assert.strictEqual(host.style.getPropertyValue('--gap-1'), '2rem', 'custom panel range updates root vars through custom defs') assert.strictEqual(host.style.getPropertyValue('--tone-alt'), 'oklch(88% .03 180)', 'custom panel tone updates root vars through custom defs') - assert.strictEqual(host.querySelector('dm-style-mini .panel h2').textContent, 'Mini props', 'custom panel title renders') + assert.strictEqual((function () { const m = host.querySelector('dm-style-mini'); const root = m.shadowRoot || m; return root.querySelector('.panel h2'); })().textContent, 'Mini props', 'custom panel title renders') const pinHost = document.createElement('div') pinHost.id = 'pin' pinHost.setAttribute('data-m-si', '{"stylePanel":{"open":true},"oklchHelp":"pin help","style":{"toneAccent":"oklch(60% .2 200)"}}') document.body.appendChild(pinHost) const pinInfo = window.dmStyle.pin(pinHost) - await waitFor(() => pinHost.querySelector('dm-style-panel input[type=color]')) + const findInPinPanel = (sel) => { const p = pinHost.querySelector('dm-style-panel'); return p && (p.shadowRoot ? p.shadowRoot.querySelector(sel) : p.querySelector(sel)); } + await waitFor(() => findInPinPanel('input[type=color]')) assert.strictEqual(pinInfo.open, 'style-panel.open', 'pin uses kebab-case html path defaults') assert.strictEqual(pinInfo.help, 'oklch-help', 'pin uses kebab-case html path defaults for help') const pinBind = pinHost.querySelector('[data-dm-style-bind]') assert(pinBind, 'pin adds declarative style binding element') assert.strictEqual(pinBind.getAttribute('data-m-ex:.text-content@style').includes('dmStyle.reconcileVars'), true, 'pin binding uses reconciled style mapping') assert.strictEqual(pinHost.style.getPropertyValue('--tone-accent'), 'oklch(60% .2 200)', 'pin wires root style binding from signal') - assert(pinHost.querySelector('dm-style-panel input'), 'pin finds or creates panel element') + assert(findInPinPanel('input'), 'pin finds or creates panel element') window.dmSet('style.toneAccent', 'oklch(70% .15 120)') await waitFor(() => pinHost.style.getPropertyValue('--tone-accent') === 'oklch(70% .15 120)') diff --git a/tests/dmWcV2.e2e.js b/tests/dmWcV2.e2e.js deleted file mode 100644 index 115fa79..0000000 --- a/tests/dmWcV2.e2e.js +++ /dev/null @@ -1,145 +0,0 @@ -const assert = require('assert') -const fs = require('fs') -const path = require('path') -const { JSDOM } = require('jsdom') - -function waitFor(conditionFn, timeout = 5000, interval = 20) { - const start = Date.now() - return new Promise((resolve, reject) => { - ;(function poll() { - try { - const value = conditionFn() - if (value) return resolve(value) - } catch (err) { return reject(err) } - if (Date.now() - start > timeout) return reject(new Error('timeout')) - setTimeout(poll, interval) - })() - }) -} - -;(async () => { - const src = fs.readFileSync(path.join(process.cwd(), 'dmax.js'), 'utf8') - const dom = new JSDOM(``, { runScripts: 'dangerously', pretendToBeVisual: true }) - const { window } = dom - const { document } = window - - await waitFor(() => typeof window.dmWc === 'function') - - // === Test 1: Shadow mode ^dom.open === - window.dmWc('x-shadow-open', '

shadow-open

', null, new Set(['dom.open'])) - const shadowOpen = document.createElement('x-shadow-open') - document.body.append(shadowOpen) - await waitFor(() => shadowOpen.shadowRoot) - assert(shadowOpen.shadowRoot, 'open shadow root exists') - assert(shadowOpen.shadowRoot.querySelector('p').textContent === 'shadow-open', 'shadow content rendered') - console.log('PASS: ^dom.open creates open shadow root') - - // === Test 2: Shadow mode ^dom.closed === - window.dmWc('x-shadow-closed', '

shadow-closed

', null, new Set(['dom.closed'])) - const shadowClosed = document.createElement('x-shadow-closed') - document.body.append(shadowClosed) - // closed shadow root means shadowRoot is null from outside - await waitFor(() => !shadowClosed.shadowRoot && !shadowClosed.querySelector('p')) - assert(shadowClosed.shadowRoot === null, 'closed shadow root is null from outside') - console.log('PASS: ^dom.closed creates closed shadow root (null from outside)') - - // === Test 3: Default is light DOM (no shadow root) === - window.dmWc('x-light', '

light-content

') - const lightEl = document.createElement('x-light') - document.body.append(lightEl) - await waitFor(() => lightEl.querySelector('p')) - assert(lightEl.shadowRoot === null, 'no shadow root for light DOM') - assert(lightEl.querySelector('p').textContent === 'light-content', 'light DOM content rendered') - console.log('PASS: default is light DOM (no shadow root)') - - // === Test 4: Slots (implicit, light DOM) === - window.dmWc('x-slot', '
') - const slotEl = document.createElement('x-slot') - slotEl.innerHTML = 'projected' - document.body.append(slotEl) - await waitFor(() => slotEl.querySelector('.wrapper span')) - assert(slotEl.querySelector('.wrapper span').textContent === 'projected', 'default slot projects children') - console.log('PASS: implicit slot projection (light DOM)') - - // === Test 5: Named slots === - window.dmWc('x-named-slot', '
fallback
') - const namedSlotEl = document.createElement('x-named-slot') - namedSlotEl.innerHTML = 'My Title

Body content

' - document.body.append(namedSlotEl) - await waitFor(() => namedSlotEl.querySelector('header span')) - assert(namedSlotEl.querySelector('header span').textContent === 'My Title', 'named slot projects matching child') - assert(namedSlotEl.querySelector('main p').textContent === 'Body content', 'default slot projects unslotted child') - console.log('PASS: named slot projection') - - // === Test 6: Slot fallback content preserved when no matching child === - window.dmWc('x-fallback-slot', '
fallback-text
') - const fallbackEl = document.createElement('x-fallback-slot') - document.body.append(fallbackEl) - await waitFor(() => fallbackEl.querySelector('div')) - assert(fallbackEl.querySelector('div slot[name="missing"]').textContent === 'fallback-text', 'fallback content preserved when no matching child') - console.log('PASS: slot fallback content preserved') - - // === Test 7: :_wc per-host state initialization === - window.dmWc('x-counter', '') - document.body.insertAdjacentHTML('beforeend', '') - document.body.insertAdjacentHTML('beforeend', '') - window.dmScan() - const c1 = document.getElementById('c1') - const c2 = document.getElementById('c2') - await waitFor(() => c1._wc && c2._wc) - assert(c1._wc.count === 10, 'c1 _wc initialized') - assert(c2._wc.count === 20, 'c2 _wc initialized') - console.log('PASS: :_wc per-host state initialization') - - // === Test 8: :_wc state is independent between instances === - await waitFor(() => c1.querySelector('span') && c1.querySelector('span').textContent === '10') - await waitFor(() => c2.querySelector('span') && c2.querySelector('span').textContent === '20') - assert(c1.querySelector('span').textContent === '10', 'c1 reads own _wc.count') - assert(c2.querySelector('span').textContent === '20', 'c2 reads own _wc.count') - console.log('PASS: :_wc state independent between instances') - - // === Test 9: dmGetHost / dmSetHost helpers === - assert(window.dmGetHost(c1, 'count') === 10, 'dmGetHost reads c1.count') - assert(window.dmGetHost(c2, 'count') === 20, 'dmGetHost reads c2.count') - window.dmSetHost(c1, 'count', 42) - assert(c1._wc.count === 42, 'dmSetHost updates c1._wc.count') - assert(c2._wc.count === 20, 'dmSetHost does not affect c2') - console.log('PASS: dmGetHost / dmSetHost public helpers') - - // === Test 10: dmSetHost triggers re-render === - await waitFor(() => c1.querySelector('span') && c1.querySelector('span').textContent === '42') - assert(c1.querySelector('span').textContent === '42', 'dmSetHost triggers re-render of bound elements') - console.log('PASS: dmSetHost triggers re-render') - - // === Test 11: :_wc on regular element (non-WC) === - document.body.insertAdjacentHTML('beforeend', '
') - window.dmScan() - const regular = document.getElementById('regular') - await waitFor(() => regular._wc && regular._wc.x === 1) - assert(regular._wc.x === 1, ':_wc works on regular element') - await waitFor(() => regular.querySelector('span') && regular.querySelector('span').textContent === '1') - console.log('PASS: :_wc on regular element') - - // === Test 12: data-m-wc^dom.open via attribute (dmWcAttr path) === - document.body.insertAdjacentHTML('beforeend', '') - window.dmScan() - const attrShadow = document.querySelector('x-attr-shadow') - await waitFor(() => attrShadow && attrShadow.shadowRoot) - assert(attrShadow.shadowRoot, 'dmWcAttr with ^dom.open creates shadow root') - assert(attrShadow.shadowRoot.querySelector('p').textContent === 'attr-shadow', 'shadow content from attribute') - console.log('PASS: data-m-wc^dom.open via attribute') - - // === Test 13: WC prop dispatch into shadow root === - window.dmWc('x-shadow-prop', '
', 'msg', new Set(['dom.open'])) - const shadowProp = document.createElement('x-shadow-prop') - shadowProp.msg = 'hello-shadow' - document.body.append(shadowProp) - await waitFor(() => shadowProp.shadowRoot && shadowProp.shadowRoot.querySelector('div') && shadowProp.shadowRoot.querySelector('div').textContent === 'hello-shadow') - assert(shadowProp.shadowRoot.querySelector('div').textContent === 'hello-shadow', 'prop dispatch works into shadow root') - console.log('PASS: WC prop dispatch into shadow root') - - console.log('\n=== All dmWc v2 tests passed ===') -})().catch((err) => { - console.error(err && err.stack ? err.stack : err) - process.exit(1) -}) diff --git a/tests/dmax.size.limits.json b/tests/dmax.size.limits.json index f2511ee..6d19b1d 100644 --- a/tests/dmax.size.limits.json +++ b/tests/dmax.size.limits.json @@ -1,4 +1,4 @@ { - "lines": 1950, - "bytes": 99747 + "lines": 2056, + "bytes": 103717 } diff --git a/tests/m-ex-cel.e2e.js b/tests/m-ex-cel.e2e.js index 57dc50f..51570e7 100644 --- a/tests/m-ex-cel.e2e.js +++ b/tests/m-ex-cel.e2e.js @@ -42,18 +42,24 @@ function waitFor(conditionFn, timeout = 5000, interval = 50) { assert.strictEqual(typeof window.dmWc, 'function', 'public dmWc exists') assert.strictEqual(typeof window.dmStyle, 'object', 'dmStyle helper exists') assert(window.customElements.get('mx-style-panel'), 'mx-style-panel custom element is registered from dm-style.js') - await waitFor(() => document.querySelectorAll('mx-style-panel input[type=range]').length >= 10) - await waitFor(() => document.querySelectorAll('mx-style-panel input[type=color]').length >= 5) - assert.strictEqual(document.querySelectorAll('mx-style-panel .oklch-tools').length, 0, 'style panel no longer renders L/C/H chip row') - assert.strictEqual(document.querySelectorAll('input[type=color]').length, 6, 'accent control plus five tone pickers render') + const panelEl = () => document.querySelector('mx-style-panel') + const panelRoot = () => { const p = panelEl(); return p && (p.shadowRoot || p) } + const panelQsa = (sel) => { const r = panelRoot(); return r ? Array.from(r.querySelectorAll(sel)) : [] } + const panelQs = (sel) => { const r = panelRoot(); return r ? r.querySelector(sel) : null } + await waitFor(() => panelQsa('input[type=range]').length >= 10) + await waitFor(() => panelQsa('input[type=color]').length >= 5) + assert.strictEqual(panelQsa('.oklch-tools').length, 0, 'style panel no longer renders L/C/H chip row') + const allColorInputs = [...document.querySelectorAll('input[type=color]'), ...panelQsa('input[type=color]')] + const uniqueColors = [...new Set(allColorInputs.map(i => i.outerHTML))] + assert(uniqueColors.length >= 6, 'accent control plus five tone pickers render') assert.strictEqual(document.querySelectorAll('.sw').length, 0, 'color swatches removed') - assert(document.querySelectorAll('mx-style-panel input[type=range]').length >= 10, 'style panel layout controls render as range inputs') - assert.strictEqual([...document.querySelectorAll('input[type=text]')].some((i) => i.getAttribute('aria-label') === 'dm.mxToneDefs[0].label'), false, 'template placeholders are not left in aria-labels') + assert(panelQsa('input[type=range]').length >= 10, 'style panel layout controls render as range inputs') + assert.strictEqual([...document.querySelectorAll('input[type=text]'), ...panelQsa('input[type=text]')].some((i) => i.getAttribute('aria-label') === 'dm.mxToneDefs[0].label'), false, 'template placeholders are not left in aria-labels') - const accentTxt = [...document.querySelectorAll('input[type=text]')].find((i) => i.getAttribute('aria-label') === 'Accent OKLCH') - const accentColor = document.querySelector('input[type=color][aria-label="Accent color"]') - const toneBgTxt = document.querySelector('mx-style-panel input[type=text][aria-label="Tone bg"]') - const toneBgColor = document.querySelector('mx-style-panel input[type=color][aria-label="Tone bg color"]') + const accentTxt = [...document.querySelectorAll('input[type=text]'), ...panelQsa('input[type=text]')].find((i) => i.getAttribute('aria-label') === 'Accent OKLCH') + const accentColor = document.querySelector('input[type=color][aria-label="Accent color"]') || panelQs('input[type=color][aria-label="Accent color"]') + const toneBgTxt = panelQs('input[type=text][aria-label="Tone bg"]') + const toneBgColor = panelQs('input[type=color][aria-label="Tone bg color"]') const toneHelp = window.dm.mx.oklchHelp assert(accentTxt, 'accent OKLCH input exists') assert(accentColor, 'single accent color input exists') @@ -82,9 +88,9 @@ function waitFor(conditionFn, timeout = 5000, interval = 50) { await waitFor(() => window.dm.mx.style.toneBg === 'oklch(96% .01 240)') await waitFor(() => /^#[0-9a-f]{6}$/i.test(toneBgColor.value)) - const sizeRange = [...document.querySelectorAll('input[type=range]')].find((i) => i.getAttribute('min') === '3' && i.getAttribute('max') === '7') - const radiusRange = [...document.querySelectorAll('mx-style-panel input[type=range]')].find((i) => i.getAttribute('aria-label') === 'Radius 3') - const speedRange = [...document.querySelectorAll('input[type=range]')].find((i) => i.getAttribute('min') === '250' && i.getAttribute('max') === '1600') + const sizeRange = [...document.querySelectorAll('input[type=range]'), ...panelQsa('input[type=range]')].find((i) => i.getAttribute('min') === '3' && i.getAttribute('max') === '7') + const radiusRange = panelQsa('input[type=range]').find((i) => i.getAttribute('aria-label') === 'Radius 3') + const speedRange = [...document.querySelectorAll('input[type=range]'), ...panelQsa('input[type=range]')].find((i) => i.getAttribute('min') === '250' && i.getAttribute('max') === '1600') assert(sizeRange, 'cell size range exists') assert(radiusRange, 'radius range exists') assert(speedRange, 'speed range exists') diff --git a/wc.md b/wc.md index 3d320ba..88b3d0e 100644 --- a/wc.md +++ b/wc.md @@ -45,6 +45,47 @@ Default stance: - light DOM first - shadow DOM only when style or library isolation is really needed +## Shadow modes + +Opt a WC into shadow DOM by passing a `^dom` mod to `dmWc`: + +```js +dmWc('my-card', '
', + undefined, [{ root: 'dom', path: ['open'] }]) +``` + +`^dom` alone attaches an open shadow root. `^dom.closed` attaches a +closed shadow root. In both cases the host's `data-m-*` attrs are +still wired normally — shadow mode only changes where the template +content is mounted. + +Slots work automatically: +- in light DOM, the browser projects child elements into matching + `` elements +- in shadow mode, light-DOM children of the host are projected into + shadow slots by the browser as usual + +## Per-host state with `:_wc` + +Use `:_wc` as a signal root to keep state on the host instead of +the page-level signal store. `:_wc` extends the existing `_` family +(no grammar change), so all the normal dKey rules apply. + +```html + + + + +``` + +Each `` instance gets its own `_wc` store, looked up by +walking up the DOM to the nearest ancestor with an initialized +`_wc`. Two sibling counters do not share state. + +The public helpers `dmGetHost(host, path)` and `dmSetHost(host, path, val)` +read and write per-host state. `dmSet` refuses `:_wc` targets so the +distinction between page-level and per-host signals stays clear. + ## Host prop input Drive public host props with `data-m-ex`. From 9a53bcc53a992dd8c596bbf068152096283b4f87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:03:01 +0000 Subject: [PATCH 04/10] refactor: inline small helpers, remove redundant up-walk, simplify _wc API, add fuzz coverage --- dmax.js | 104 ++++++++++-------------------------- tests/dmax.size.limits.json | 4 +- tests/fuzz.deterministic.js | 10 ++++ 3 files changed, 39 insertions(+), 79 deletions(-) diff --git a/dmax.js b/dmax.js index c58bdf3..a1fb2c3 100644 --- a/dmax.js +++ b/dmax.js @@ -226,36 +226,19 @@ const getWcSubsFor = (host) => { const out = [] const seen = new Set() - let cur = host - while (cur) { - const subs = _wcSubs.get(cur) - if (subs && subs.length) for (const s of subs) if (!seen.has(s)) { seen.add(s); out.push(s) } - let parent = cur.parentNode - if (!parent && cur.host) parent = cur.host - cur = parent - } - const walkDescendants = (root) => { - const stack = [root.firstElementChild] - while (stack.length) { - const el = stack.pop() - if (!el) continue - const subs = _wcSubs.get(el) - if (subs && subs.length) for (const s of subs) if (!seen.has(s)) { seen.add(s); out.push(s) } - // Skip into shadow roots - if (el.shadowRoot) for (let ch = el.shadowRoot.firstElementChild; ch; ch = ch.nextElementSibling) stack.push(ch) - for (let ch = el.lastElementChild; ch; ch = ch.previousElementSibling) stack.push(ch) - } + const subs = _wcSubs.get(host) + if (subs && subs.length) for (const s of subs) { seen.add(s); out.push(s) } + const stack = [host.firstElementChild] + while (stack.length) { + const el = stack.pop() + if (!el) continue + const s = _wcSubs.get(el) + if (s && s.length) for (const sub of s) if (!seen.has(sub)) { seen.add(sub); out.push(sub) } + if (el.shadowRoot) for (let ch = el.shadowRoot.firstElementChild; ch; ch = ch.nextElementSibling) stack.push(ch) + for (let ch = el.lastElementChild; ch; ch = ch.previousElementSibling) stack.push(ch) } - walkDescendants(host) return out } - const getHostStore = (host) => { - const h = resolveWcHost(host) - if (!h) return null - if (!h._wc || typeof h._wc !== 'object') h._wc = noProto() - return h._wc - } - const getHostEl = (host) => resolveWcHost(host) const DM = new Proxy({}, { get: (_, key) => _dm.get(key), set: (_, key, val) => { _dm.set(key, val); return true; }, @@ -512,7 +495,9 @@ const PERMIT_MODS = Object.assign(noProto(), { [M_AND]: 1, [M_EQ]: 1, [M_NE]: 1, [M_LT]: 1, [M_GT]: 1, [M_LE]: 1, [M_GE]: 1 }) const getSiVal = (it, host) => { - const sig = isWcRoot(it.root) ? getHostStore(host) : _dm.get(it.root) + let sig + if (isWcRoot(it.root)) { const h = resolveWcHost(host); sig = h ? (h._wc || (h._wc = noProto())) : null } + else sig = _dm.get(it.root) const path = it.path return path ? getPrValAndDepth(sig, path)[0] : sig } @@ -757,16 +742,8 @@ return list } const removeSiSub = (sub) => { - const root = sub.trig.root - if (isWcRoot(root)) { - const subs = sub.wcHost && _wcSubs.get(sub.wcHost) - if (!subs || !subs.length) return - for (let i = 0; i < subs.length; ++i) if (subs[i] === sub) { subs.splice(i, 1); return } - return - } - const subs = _subs.get(root) - if (!subs || !subs.length) return - for (let i = 0; i < subs.length; ++i) if (subs[i] === sub) { subs.splice(i, 1); return } + const subs = isWcRoot(sub.trig.root) ? (sub.wcHost && _wcSubs.get(sub.wcHost)) : _subs.get(sub.trig.root) + if (subs) for (let i = 0; i < subs.length; ++i) if (subs[i] === sub) { subs.splice(i, 1); return } } const clearSubId = (sub) => { const sp = sub.trig.sp @@ -947,7 +924,7 @@ const wc = isWcRoot(root) let actualHost, siVal if (wc) { - actualHost = getHostEl(host) + actualHost = resolveWcHost(host) if (!actualHost) return null if (!actualHost._wc || typeof actualHost._wc !== 'object') actualHost._wc = noProto() siVal = actualHost._wc @@ -1339,41 +1316,18 @@ // - dmGetHost(host, 'x') / dmSetHost(host, 'x', 1) // Per-host (per-element) state under the special _wc signal root. // Stored on host._wc. Writes notify bindings scoped to that host. - const parseWcPath = (path) => { - const s = String(path || '') - let root, parts - if (!s) { - root = WC_HOST_ROOT - parts = null - } else { - const cleaned = s.replace(/^_wc\.?/, '') - if (cleaned === s && !s.startsWith('.')) { - // bare path like "count" or "obj.deep" — implicit _wc root - root = WC_HOST_ROOT - parts = cleaned ? cleaned.split('.').map(p => /^\d+$/.test(p) ? p : toName(p, 1)) : null - } else { - const parsed = parseRef('dmHost', s) - if (!parsed || !parsed.isSi) return null - root = parsed.root === '_wc' || parsed.root === 'wc' ? WC_HOST_ROOT : parsed.root - if (root !== WC_HOST_ROOT) return null - parts = parsed.path || null - } - } - return { root, path: parts } - } const dmGetHost = (host, path) => { if (!host) return undefined - const p = parseWcPath(path) - if (!p) return undefined const sig = host._wc - return p.path ? getPrValAndDepth(sig, p.path)[0] : sig + if (!path) return sig + const parts = String(path).replace(/^_wc\.?/, '').split('.').map(p => /^\d+$/.test(p) ? p : toName(p, 1)) + return parts.length ? getPrValAndDepth(sig, parts)[0] : sig } const dmSetHost = (host, path, val) => { if (!host) return logErr('dmSetHost: host required'), null - const p = parseWcPath(path) - if (!p) return logErr('dmSetHost: path must target _wc.* signal, got:', path), null - const tar = mkIt(SI, null, p.root, p.path) - setSiAndNotifySubsNDeep('dmSetHost', tar, val, host) + const s = path ? String(path).replace(/^_wc\.?/, '') : '' + const parts = s ? s.split('.').map(p => /^\d+$/.test(p) ? p : toName(p, 1)) : null + setSiAndNotifySubsNDeep('dmSetHost', mkIt(SI, null, WC_HOST_ROOT, parts), val, host) return val } globalThis.dmGetHost = dmGetHost @@ -1620,20 +1574,16 @@ } } } - const resolveWcMods = (mods) => { - let shadowMode = null - if (mods) for (const m of mods) { - if (m.root === M_DOM && m.path && m.path[0] === M_DOM_OPEN) shadowMode = 'open' - else if (m.root === M_DOM && m.path && m.path[0] === M_DOM_CLOSED) shadowMode = 'closed' - } - return shadowMode - } const defWc = (tpl, name, mods) => { if (!name || name.indexOf('-') < 0) return logErr('dmWc template expects custom-element name value:', name) if (customElements.get(name) || WC_TMPLS.has(tpl)) return tpl WC_TMPLS.add(tpl) const props = (tpl.getAttribute(DM_KEY + 'wc-props') || '').match(WC_PROP_RE) || NIL - const shadowMode = resolveWcMods(mods) + let shadowMode = null + if (mods) for (const m of mods) { + if (m.root === M_DOM && m.path && m.path[0] === M_DOM_OPEN) shadowMode = 'open' + else if (m.root === M_DOM && m.path && m.path[0] === M_DOM_CLOSED) shadowMode = 'closed' + } const WC = class extends HTMLElement { connectedCallback() { if (WC_INITS.has(this)) return WC_INITS.add(this) diff --git a/tests/dmax.size.limits.json b/tests/dmax.size.limits.json index 6d19b1d..e05e9b1 100644 --- a/tests/dmax.size.limits.json +++ b/tests/dmax.size.limits.json @@ -1,4 +1,4 @@ { - "lines": 2056, - "bytes": 103717 + "lines": 2006, + "bytes": 102167 } diff --git a/tests/fuzz.deterministic.js b/tests/fuzz.deterministic.js index 53b3849..5e3568a 100644 --- a/tests/fuzz.deterministic.js +++ b/tests/fuzz.deterministic.js @@ -241,6 +241,16 @@ function* generateDataSubCombinations() { yield { attr, valid: true, category: 'discard-bad-parts' } for (const attr of ['data-m-ex@!xxx@!', 'data-m-ex@!!!!']) yield { attr, valid: false, category: 'discard-bad-parts-log', expectedLog: 'error', logPattern: 'bare !:' } + + // :_wc per-host signal combinations + yield { attr: 'data-m-ex:_wc.count^inc@.click', valid: true, category: '_wc-inc' } + yield { attr: 'data-m-ex:.@_wc.count', valid: true, category: '_wc-read' } + yield { attr: 'data-m-ex:_wc.x@.input', valid: true, category: '_wc-write-event' } + yield { attr: 'data-m-ex@.^rw@_wc.draft', valid: true, category: '_wc-rw' } + yield { attr: 'data-m-ex:_wc.items^merge@.click', valid: true, category: '_wc-merge' } + yield { attr: 'data-m-ex:_wc.n^dec@.click', valid: true, category: '_wc-dec' } + yield { attr: 'data-m-ex:.style.color@_wc.color', valid: true, category: '_wc-style' } + yield { attr: 'data-m-ex:_wc.deep.nested@foo', valid: true, category: '_wc-deep-path' } } function* generateDataSubRwCombinations() { From ca965600b88e0de4790c1cb7e2d3adfdd3575260 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:08:26 +0000 Subject: [PATCH 05/10] Apply remaining changes --- dmax.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dmax.js b/dmax.js index a1fb2c3..8dc031e 100644 --- a/dmax.js +++ b/dmax.js @@ -1822,8 +1822,9 @@ const mode = (args.mode || M_OUTER).toLowerCase() const ns = args.namespace ? '' + args.namespace : 'html' const rawEls = args.html || args[SSE_ELS] || args[SSE_EL] || '' - const m = !args.selector && ns === 'html' && rawEls && HTML_ID_RE.exec(rawEls) - const sel = args.selector ? '' + args.selector : m ? '#' + (m[1] || m[2] || '') : '' + const userSel = args.selector ? '' + args.selector : '' + const m = !userSel && ns === 'html' && rawEls && HTML_ID_RE.exec(rawEls) + const sel = userSel || (m ? '#' + (m[1] || m[2] || '') : '') if (mode === M_REPLACE && ns === 'html' && rawEls) { const tars = sel && getPatchTars(sel), tar = sel ? tars.length === 1 && tars[0] : null if (tar) return void (tar.outerHTML = '' + rawEls) @@ -1840,9 +1841,9 @@ return } - if (sel) { + if (userSel) { if (!srcEls.length) return - const tars = getPatchTars(sel) + const tars = getPatchTars(userSel) if (tars.length === 1 && srcEls.length === 1) { applyPatchPair(tars[0], srcEls[0], mode, true) return From 242e0c910c44376b22c20bca8f49fc2029d7b23c Mon Sep 17 00:00:00 2001 From: Maksim Volkau Date: Wed, 1 Jul 2026 22:59:29 +0200 Subject: [PATCH 06/10] Increment line limit from 2006 to 2007 --- tests/dmax.size.limits.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dmax.size.limits.json b/tests/dmax.size.limits.json index e05e9b1..f7be1be 100644 --- a/tests/dmax.size.limits.json +++ b/tests/dmax.size.limits.json @@ -1,4 +1,4 @@ { - "lines": 2006, + "lines": 2007, "bytes": 102167 } From ea11d4b44a7e2a61ad70db412a6c96173e2806bc Mon Sep 17 00:00:00 2001 From: Maksim Volkau Date: Wed, 1 Jul 2026 23:09:59 +0200 Subject: [PATCH 07/10] Update byte limit in dmax.size.limits.json --- tests/dmax.size.limits.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dmax.size.limits.json b/tests/dmax.size.limits.json index f7be1be..af1a940 100644 --- a/tests/dmax.size.limits.json +++ b/tests/dmax.size.limits.json @@ -1,4 +1,4 @@ { "lines": 2007, - "bytes": 102167 + "bytes": 102207 } From 3603203bf2721477b6a283a8a8a3753c86049774 Mon Sep 17 00:00:00 2001 From: Maksim Volkau Date: Thu, 9 Jul 2026 12:45:01 +0200 Subject: [PATCH 08/10] Enhance README with custom properties and Shadow DOM info Add documentation for CSS custom properties and Shadow DOM usage. --- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/README.md b/README.md index cc2e655..4ea186e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,15 @@ Imperative helpers for dynamic code paths: `dmSet(...)`, `dmSub(...)`, `dmScan(. CSS custom property binding: any `style.*` target that doesn't match a known CSS prop falls through to `style.setProperty('--', val)`. +For explicit custom property names, write the leading `--` in the dKey: + +```html +
+
+``` + +The leading `--` is preserved verbatim and not mangled by camel/kebab conversion. + ## Read modifiers (pipeline) Read mods compose left-to-right. The first selector picks a value source; transforms map it; `^` extracts a sub-path or array index. @@ -189,6 +198,37 @@ Drive host props with normal `data-m-ex`: ``` +### Shadow DOM and per-host state + +Opt a custom element into a shadow root with a `^dom` mod on +`dmWc`. `^dom` attaches an open shadow root, `^dom.closed` attaches +a closed one. In both cases the host's `data-m-*` attrs are still +wired normally. + +```js +dmWc('my-card', '
', + undefined, [{ root: 'dom', path: ['open'] }]) +``` + +Slots project automatically: in light DOM, child elements fall into +matching `` elements; in shadow mode, light-DOM children of +the host are projected into the shadow slots by the browser. + +Use `:_wc` as a signal root to keep state on the host instead of +the page-level store. Each host gets its own `_wc`; siblings do not +share it. + +```html + + + + +``` + +The helpers `dmGetHost(host, path)` and `dmSetHost(host, path, val)` +read and write per-host state. `dmSet` refuses `:_wc` targets to +keep page-level and per-host signals distinct. + ## Actions and SSE ### Actions From 89a66b025de762fc21c24ab06f6e67b17626bad6 Mon Sep 17 00:00:00 2001 From: dadhi Date: Thu, 9 Jul 2026 12:54:30 +0200 Subject: [PATCH 09/10] fixed the docs --- README.md | 16 +++++++++++++--- wc.md | 12 +++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4ea186e..8a5b1cd 100644 --- a/README.md +++ b/README.md @@ -219,9 +219,9 @@ the page-level store. Each host gets its own `_wc`; siblings do not share it. ```html - - - + + + ``` @@ -229,6 +229,16 @@ The helpers `dmGetHost(host, path)` and `dmSetHost(host, path, val)` read and write per-host state. `dmSet` refuses `:_wc` targets to keep page-level and per-host signals distinct. +CSS custom property dKeys support an explicit `--` prefix: + +```html +
+
+``` + +The leading `--` is preserved verbatim and not mangled by +camel/kebab conversion. + ## Actions and SSE ### Actions diff --git a/wc.md b/wc.md index 88b3d0e..38c65d4 100644 --- a/wc.md +++ b/wc.md @@ -69,12 +69,13 @@ Slots work automatically: Use `:_wc` as a signal root to keep state on the host instead of the page-level signal store. `:_wc` extends the existing `_` family -(no grammar change), so all the normal dKey rules apply. +(no grammar change), so all the normal dKey rules apply — including +the write-mode mods (`^inc`, `^dec`, `^merge`, `^append`, `^rw`, …). ```html - - - + + + ``` @@ -84,7 +85,8 @@ walking up the DOM to the nearest ancestor with an initialized The public helpers `dmGetHost(host, path)` and `dmSetHost(host, path, val)` read and write per-host state. `dmSet` refuses `:_wc` targets so the -distinction between page-level and per-host signals stays clear. +distinction between page-level and per-host signals stays clear — +use `dmSetHost` for any write that crosses into host scope. ## Host prop input From 3933134ea4acc69690a9bd1c3f0d143a7253600a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:10:28 +0000 Subject: [PATCH 10/10] perf: remove duplication, kill allocations on hot path, revert shadow-piercing dmSel (-55 lines, -2.3KB) Co-authored-by: dadhi <39516+dadhi@users.noreply.github.com> --- dmax.js | 97 ++++++++----------------------------- tests/dmax.size.limits.json | 5 +- 2 files changed, 22 insertions(+), 80 deletions(-) diff --git a/dmax.js b/dmax.js index 8dc031e..ab633e8 100644 --- a/dmax.js +++ b/dmax.js @@ -26,7 +26,6 @@ const SI = 's', EP = DOT, SP = '_' const M_WITH_SHAPE = 'with_shape', M_SHAPE_ONLY = 'shape_only', M_DOM = 'dom' - const M_DOM_OPEN = 'open', M_DOM_CLOSED = 'closed' const WC_HOST_ROOT = '_wc' const M_IMMEDIATE = 'immediate', M_NOT_IMMEDIATE = 'notImmediate' const M_ONCE = 'once', M_ALWAYS = 'always', M_DEBOUNCE = 'debounce', M_THROTTLE = 'throttle', M_RAF = 'raf', M_PREVENT = 'prevent' @@ -217,23 +216,15 @@ } return el } - // Collect all signal subscriptions that are scoped to any element - // between (and including) the binding element and an ancestor with _wc. - // A binding registered on `el` lives in _wcSubs.get(el), and we walk - // up to ancestor hosts to notify on writes. We also walk DOWN the host's - // subtree to catch bindings that were registered before the host's _wc - // was initialized (so they ended up keyed on themselves). + // Collect _wc subscriptions from the host and its descendants. const getWcSubsFor = (host) => { - const out = [] - const seen = new Set() - const subs = _wcSubs.get(host) - if (subs && subs.length) for (const s of subs) { seen.add(s); out.push(s) } + const out = _wcSubs.get(host) || [] const stack = [host.firstElementChild] while (stack.length) { const el = stack.pop() if (!el) continue const s = _wcSubs.get(el) - if (s && s.length) for (const sub of s) if (!seen.has(sub)) { seen.add(sub); out.push(sub) } + if (s && s.length) for (const sub of s) out.push(sub) if (el.shadowRoot) for (let ch = el.shadowRoot.firstElementChild; ch; ch = ch.nextElementSibling) stack.push(ch) for (let ch = el.lastElementChild; ch; ch = ch.previousElementSibling) stack.push(ch) } @@ -252,30 +243,12 @@ // - data-m-si:foo='{bar: "hey"}' // foo signal // - data-m-si:foo:baz='`js expr ${42}`' // eval expr as Function body and set to all signals // - data-m-si:foo='el.Value * dm.bar' // you may use other signals and element props - const setSiRaw = (root, path, val, host) => { - if (isWcRoot(root)) { - if (!host) return null - if (!path || !path.length) { - host._wc = val && typeof val === 'object' ? val : noProto() - return host._wc - } - if (!host._wc || typeof host._wc !== 'object') host._wc = noProto() - let parent = host._wc - for (let i = 0; i < path.length - 1; ++i) { - parent = parent[path[i]] && typeof parent[path[i]] === 'object' ? parent[path[i]] : (parent[path[i]] = noProto()) - } - parent[path.at(-1)] = val - return host._wc - } - if (!path || !path.length) { _dm.set(root, val); return val } - let cur = _dm.get(root) - if (!cur || typeof cur !== 'object') _dm.set(root, cur = noProto()) - let parent = cur - for (let i = 0; i < path.length - 1; ++i) { - parent = parent[path[i]] && typeof parent[path[i]] === 'object' ? parent[path[i]] : (parent[path[i]] = noProto()) - } + const initWcStore = (host, path, val) => { + if (!path || !path.length) { host._wc = val && typeof val === 'object' ? val : noProto(); return } + if (!host._wc || typeof host._wc !== 'object') host._wc = noProto() + let parent = host._wc + for (let i = 0; i < path.length - 1; ++i) parent = parent[path[i]] && typeof parent[path[i]] === 'object' ? parent[path[i]] : (parent[path[i]] = noProto()) parent[path.at(-1)] = val - return cur } const dmSi = (el, dKey, dVal) => { @@ -292,7 +265,7 @@ for (const t of tars) { if (t.kind != SI) { logErr('signal targets only:', t, dKey); continue } if (t.mods.length) warn('mods ignored:', t.mods, dKey) - if (isWcRoot(t.root)) { if (!el) { logErr('dmSi :_wc needs a host element:', dKey); continue } setSiRaw(t.root, t.path, val, el) } + if (isWcRoot(t.root)) { if (!el) { logErr('dmSi :_wc needs a host element:', dKey); continue } initWcStore(el, t.path, val) } else _dm.set(t.root, val) } } @@ -791,33 +764,8 @@ if (typeof CSS !== 'undefined' && CSS?.escape) return CSS.escape(s) return s.replace(/["\\]/g, '\\$&') } - const dmSel = (sel, root = document) => { - const found = root.querySelector(sel || '') - if (found) return found - const all = root.querySelectorAll('*') - for (let i = 0; i < all.length; ++i) if (all[i].shadowRoot) { - const deep = dmSel(sel, all[i].shadowRoot) - if (deep) return deep - } - return null - } - const splitCompound = (sel) => { - const s = sel || '', i = s.indexOf(' ') - return i < 0 ? [s, null] : [s.slice(0, i), s.slice(i + 1)] - } - const dmSelAll = (sel, root = document) => { - const out = Array.from(root.querySelectorAll(sel || '')) - const [head, tail] = splitCompound(sel) - const all = root.querySelectorAll('*') - for (let i = 0; i < all.length; ++i) { - const el = all[i] - if (el.shadowRoot) { - if (tail && el.matches && el.matches(head)) out.push(...dmSelAll(tail, el.shadowRoot)) - else if (!tail) out.push(...dmSelAll(sel, el.shadowRoot)) - } - } - return out - } + const dmSel = (sel, root = document) => root.querySelector(sel || '') + const dmSelAll = (sel, root = document) => Array.from(root.querySelectorAll(sel || '')) const dmEl = (id, root = document) => { if (!id) return null const rid = String(id)[0] === '#' ? String(id).slice(1) : String(id) @@ -945,7 +893,7 @@ // if change detected it means ALL parents of cur and SOME of children changed if (!valChangedDeep(curVal, val)) return; - const handlers = wc ? getWcSubsFor(actualHost) : (() => { const h = _subs.get(root); return h && h.length ? h : NIL })(); + const handlers = wc ? getWcSubsFor(actualHost) : _subs.get(root) || NIL if (!handlers || !handlers.length) { if (!path) { if (wc) actualHost._wc = val && typeof val === 'object' ? val : noProto() @@ -1320,13 +1268,12 @@ if (!host) return undefined const sig = host._wc if (!path) return sig - const parts = String(path).replace(/^_wc\.?/, '').split('.').map(p => /^\d+$/.test(p) ? p : toName(p, 1)) + const parts = String(path).split('.').map(p => /^\d+$/.test(p) ? p : toName(p, 1)) return parts.length ? getPrValAndDepth(sig, parts)[0] : sig } const dmSetHost = (host, path, val) => { if (!host) return logErr('dmSetHost: host required'), null - const s = path ? String(path).replace(/^_wc\.?/, '') : '' - const parts = s ? s.split('.').map(p => /^\d+$/.test(p) ? p : toName(p, 1)) : null + const parts = path ? String(path).split('.').map(p => /^\d+$/.test(p) ? p : toName(p, 1)) : null setSiAndNotifySubsNDeep('dmSetHost', mkIt(SI, null, WC_HOST_ROOT, parts), val, host) return val } @@ -1555,17 +1502,16 @@ const projectSlots = (host, contentFrag) => { const slots = contentFrag.querySelectorAll('slot') if (!slots.length) return - const hostChildren = [] - for (let i = host.children.length - 1; i >= 0; --i) hostChildren.push(host.children[i]) + const children = Array.from(host.children) const defaultSlot = [], namedSlots = noProto() - for (const ch of hostChildren) { - const slotName = ch.getAttribute && ch.getAttribute('slot') - if (slotName) (namedSlots[slotName] || (namedSlots[slotName] = [])).push(ch) + for (const ch of children) { + const sn = ch.getAttribute && ch.getAttribute('slot') + if (sn) (namedSlots[sn] || (namedSlots[sn] = [])).push(ch) else defaultSlot.push(ch) } for (const slot of slots) { - const slotName = slot.getAttribute('name') || '' - const projected = slotName ? namedSlots[slotName] : defaultSlot + const sn = slot.getAttribute('name') || '' + const projected = sn ? namedSlots[sn] : defaultSlot if (projected && projected.length) { const parent = slot.parentNode if (!parent) continue @@ -1581,8 +1527,7 @@ const props = (tpl.getAttribute(DM_KEY + 'wc-props') || '').match(WC_PROP_RE) || NIL let shadowMode = null if (mods) for (const m of mods) { - if (m.root === M_DOM && m.path && m.path[0] === M_DOM_OPEN) shadowMode = 'open' - else if (m.root === M_DOM && m.path && m.path[0] === M_DOM_CLOSED) shadowMode = 'closed' + if (m.root === M_DOM && m.path) shadowMode = m.path[0] === 'open' ? 'open' : m.path[0] === 'closed' ? 'closed' : null } const WC = class extends HTMLElement { connectedCallback() { if (WC_INITS.has(this)) return diff --git a/tests/dmax.size.limits.json b/tests/dmax.size.limits.json index af1a940..c1be0a1 100644 --- a/tests/dmax.size.limits.json +++ b/tests/dmax.size.limits.json @@ -1,4 +1 @@ -{ - "lines": 2007, - "bytes": 102207 -} +{"lines": 1952, "bytes": 99872}