diff --git a/README.md b/README.md index cc2e655..8a5b1cd 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,47 @@ 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. + +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/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 73f8236..ab633e8 100644 --- a/dmax.js +++ b/dmax.js @@ -25,7 +25,8 @@ 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 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' @@ -102,7 +103,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 } @@ -199,6 +200,36 @@ } 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 _wc subscriptions from the host and its descendants. + const getWcSubsFor = (host) => { + 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) 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) + } + return out + } const DM = new Proxy({}, { get: (_, key) => _dm.get(key), set: (_, key, val) => { _dm.set(key, val); return true; }, @@ -212,6 +243,14 @@ // - 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 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 + } + 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) @@ -220,13 +259,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) - _dm.set(t.root, val) + 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) } } @@ -427,25 +467,27 @@ 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, host) => { + 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 } - 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) @@ -575,11 +617,11 @@ 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]) - } else { + } else if (el.attributes) { const attrs = el.attributes for (let i = 0; i < attrs.length; ++i) { const attr = attrs[i] @@ -655,10 +697,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 @@ -673,9 +715,8 @@ return list } const removeSiSub = (sub) => { - 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 } + 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 @@ -695,8 +736,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, 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) } @@ -789,7 +830,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 (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 } const sp = tr.sp @@ -819,13 +866,26 @@ }) } - const setSiAndNotifySubs = (dKey, tar, val) => { + const setSiAndNotifySubs = (dKey, tar, val, host) => { const root = tar?.root, path = tar?.path if (!root) return null - let siVal = _dm.get(root), curVal = siVal, parent = siVal, d = 0, last = null + const wc = isWcRoot(root) + let actualHost, siVal + if (wc) { + actualHost = resolveWcHost(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]] } @@ -833,9 +893,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) : _subs.get(root) || 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 } @@ -879,21 +942,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) => { + 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) } finally { syncDepth-- } + try { return setSiAndNotifySubs(dKey, tar, val, host) } finally { syncDepth-- } } /** @@ -949,7 +1015,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 +1076,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 +1099,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) } @@ -1161,6 +1227,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 } @@ -1194,6 +1261,24 @@ globalThis.dmSet = dmSet globalThis.dmSub = dmSub globalThis.dmSel = dmSel, globalThis.dmSelAll = dmSelAll, globalThis.dmEl = dmEl + // - 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 dmGetHost = (host, path) => { + if (!host) return undefined + const sig = host._wc + if (!path) return sig + 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 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 + } + globalThis.dmGetHost = dmGetHost + globalThis.dmSetHost = dmSetHost // - data-m-it@posts // - data-m-it+#tpl-post@posts @@ -1297,13 +1382,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 @@ -1328,7 +1413,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 @@ -1368,7 +1453,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) @@ -1414,21 +1499,69 @@ // - 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 projectSlots = (host, contentFrag) => { + const slots = contentFrag.querySelectorAll('slot') + if (!slots.length) return + const children = Array.from(host.children) + const defaultSlot = [], namedSlots = noProto() + 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 sn = slot.getAttribute('name') || '' + const projected = sn ? namedSlots[sn] : defaultSlot + if (projected && projected.length) { + const parent = slot.parentNode + if (!parent) continue + for (const child of projected) parent.insertBefore(child, slot) + parent.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 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 })) } }) + let shadowMode = null + if (mods) for (const m of mods) { + 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 + 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) + } + 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, 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) // - 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 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 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 @@ -1634,8 +1767,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) @@ -1652,9 +1786,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 diff --git a/package.json b/package.json index 103e0e7..41ec5c8 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: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: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/dmax.size.limits.json b/tests/dmax.size.limits.json index 7b9ad18..c1be0a1 100644 --- a/tests/dmax.size.limits.json +++ b/tests/dmax.size.limits.json @@ -1,4 +1 @@ -{ - "lines": 1818, - "bytes": 93982 -} +{"lines": 1952, "bytes": 99872} 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() { 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..38c65d4 100644 --- a/wc.md +++ b/wc.md @@ -45,6 +45,49 @@ 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 — including +the write-mode mods (`^inc`, `^dec`, `^merge`, `^append`, `^rw`, …). + +```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 — +use `dmSetHost` for any write that crosses into host scope. + ## Host prop input Drive public host props with `data-m-ex`.