@symbo.ls/element 3.14.0 → 3.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -3
- package/src/cache.js +1 -1
- package/src/create.js +286 -24
- package/src/extends.js +28 -10
- package/src/methods.js +16 -2
- package/src/update.js +10 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbo.ls/element",
|
|
3
|
-
"version": "3.14.
|
|
4
|
-
"description": "Core element creation, update, and disposal engine for smbls
|
|
3
|
+
"version": "3.14.2",
|
|
4
|
+
"description": "Core element creation, update, and disposal engine for smbls v3.14",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
7
7
|
"module": "./index.js",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"css-in-props": "^3.14.0",
|
|
17
17
|
"attrs-in-props": "^3.14.0"
|
|
18
18
|
},
|
|
19
|
-
"files": [
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"index.js"
|
|
22
|
+
],
|
|
20
23
|
"license": "CC-BY-NC-4.0"
|
|
21
24
|
}
|
package/src/cache.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
// Node caching - cloneNode from cached prototypes instead of createElement
|
|
4
|
-
// This is already a v3 optimization that works well -- preserved in
|
|
4
|
+
// This is already a v3 optimization that works well -- preserved in v3.14
|
|
5
5
|
|
|
6
6
|
const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
7
7
|
|
package/src/create.js
CHANGED
|
@@ -90,6 +90,22 @@ export function create(definition, parent, key, options = {}, attachOptions) {
|
|
|
90
90
|
const components = element.context?.components
|
|
91
91
|
if (components && (components[baseName] || components['smbls.' + baseName])) {
|
|
92
92
|
definition = { extends: baseName, ...definition }
|
|
93
|
+
} else if (
|
|
94
|
+
// FRAMEWORK-5: dev-mode warning. A key shaped like a multi-instance
|
|
95
|
+
// suffix (e.g. `Link_1`) clearly intends to auto-extend a base
|
|
96
|
+
// component, so silently rendering as a bare div is the kind of
|
|
97
|
+
// failure that takes a click test to discover. Warn loudly in dev
|
|
98
|
+
// so the typo / missing import is visible at mount time.
|
|
99
|
+
components &&
|
|
100
|
+
process.env.NODE_ENV !== 'production' &&
|
|
101
|
+
/^[A-Z]\w*_\d+$/.test(key)
|
|
102
|
+
) {
|
|
103
|
+
console.warn(
|
|
104
|
+
`[smbls] auto-extend: key "${key}" looks like a multi-instance ` +
|
|
105
|
+
`(${baseName}_N) but neither "${baseName}" nor "smbls.${baseName}" ` +
|
|
106
|
+
`is registered in context.components — element will render as a ` +
|
|
107
|
+
`plain <div>. Register the base component or rename the key.`
|
|
108
|
+
)
|
|
93
109
|
}
|
|
94
110
|
}
|
|
95
111
|
}
|
|
@@ -155,12 +171,34 @@ export function create(definition, parent, key, options = {}, attachOptions) {
|
|
|
155
171
|
if (definition.classlist !== undefined) element.classlist = definition.classlist
|
|
156
172
|
if (definition.text !== undefined) element.text = definition.text
|
|
157
173
|
if (definition.html !== undefined) element.html = definition.html
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
174
|
+
// The scope chain at any element resolves keys in this order:
|
|
175
|
+
// own (definition.scope) → parent.scope → … → root.scope → context.globalScope
|
|
176
|
+
// Direct reads like `el.scope.LANG_META` walk the prototype chain, so a
|
|
177
|
+
// component that doesn't declare its own scope still sees parent scopes
|
|
178
|
+
// and the project's globalScope as fallbacks. Without `globalScope` at the
|
|
179
|
+
// bottom of the chain, every project pattern that puts shared constants
|
|
180
|
+
// in `globalScope.js` and reads them via `el.scope.X` was silently broken
|
|
181
|
+
// — the user's reactive function got `undefined` and either threw or
|
|
182
|
+
// rendered nothing. The frank-rewriter's `__scope` helper already chains
|
|
183
|
+
// through globalScope; this brings the runtime `el.scope` to parity.
|
|
184
|
+
// Capture scope set by the extends chain (applyExtends ran above) BEFORE
|
|
185
|
+
// we rebuild element.scope. Otherwise scope blocks declared on the base
|
|
186
|
+
// component (e.g. `Navbar.scope = { LANG_META }`) get blown away when
|
|
187
|
+
// a consumer extends without redeclaring scope (`AppNavbar: { extends:
|
|
188
|
+
// 'Navbar' }`). FA-L24.
|
|
189
|
+
const inheritedFromExtends = element.scope && typeof element.scope === 'object' ? element.scope : null
|
|
190
|
+
const ancestorScope = parent?.scope
|
|
191
|
+
const globalScope =
|
|
192
|
+
(element.context && typeof element.context.globalScope === 'object' && element.context.globalScope) || null
|
|
193
|
+
const baseScope = ancestorScope ||
|
|
194
|
+
(globalScope ? Object.create(globalScope) : null)
|
|
195
|
+
if (definition.scope !== undefined || inheritedFromExtends) {
|
|
196
|
+
const target = baseScope ? Object.create(baseScope) : {}
|
|
197
|
+
if (inheritedFromExtends) Object.assign(target, inheritedFromExtends)
|
|
198
|
+
if (definition.scope !== undefined) Object.assign(target, definition.scope)
|
|
199
|
+
element.scope = target
|
|
200
|
+
} else if (baseScope) {
|
|
201
|
+
element.scope = Object.create(baseScope)
|
|
164
202
|
}
|
|
165
203
|
if (definition.if !== undefined) element.if = definition.if
|
|
166
204
|
|
|
@@ -289,15 +327,24 @@ export function create(definition, parent, key, options = {}, attachOptions) {
|
|
|
289
327
|
triggerLifecycle('RenderRouter', element, options)
|
|
290
328
|
|
|
291
329
|
// Start onFrame loop if defined
|
|
330
|
+
// FT-FRAMEWORK-3: Previously this returned early when `!element.node.isConnected`,
|
|
331
|
+
// killing the loop. During an initial top-down mount, the first rAF often
|
|
332
|
+
// fires before the root has been attached to document.body — so deeply
|
|
333
|
+
// nested children's onFrame loops self-terminated and never restarted.
|
|
334
|
+
// Now we reschedule unconditionally and gate only the handler call on
|
|
335
|
+
// `isConnected`. The loop stops only when the element is disposed
|
|
336
|
+
// (`element.node === null`).
|
|
292
337
|
if (typeof element.onFrame === 'function') {
|
|
293
338
|
const frameHandler = element.onFrame
|
|
294
339
|
const frameDsConfig = element.context?.designSystem
|
|
295
340
|
const tick = () => {
|
|
296
|
-
if (!element.node
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
341
|
+
if (!element.node) return
|
|
342
|
+
if (element.node.isConnected) {
|
|
343
|
+
if (frameDsConfig) pushConfig(frameDsConfig)
|
|
344
|
+
try { frameHandler(element, element.state, element.context) } catch (e) { console.error('[DOMQL] onFrame error:', e) }
|
|
345
|
+
finally {
|
|
346
|
+
if (frameDsConfig) popConfig()
|
|
347
|
+
}
|
|
301
348
|
}
|
|
302
349
|
requestAnimationFrame(tick)
|
|
303
350
|
}
|
|
@@ -316,7 +363,21 @@ function createElementState(element, parent, definition) {
|
|
|
316
363
|
// Dashboard defines `state: { useJson: true }` would lose its state because
|
|
317
364
|
// the local definition has no `state` field, and we'd silently inherit
|
|
318
365
|
// parent state instead.
|
|
319
|
-
|
|
366
|
+
// Treat an empty plain-object `state: {}` declaration as "not provided"
|
|
367
|
+
// when this is the root element AND context.state has content. Without
|
|
368
|
+
// this, scaffolded `app.js` files that ship with `state: {}` boilerplate
|
|
369
|
+
// would silently override context.state (loaded from `state.js`) and
|
|
370
|
+
// leave the root state empty at runtime — see chatbot template where
|
|
371
|
+
// `s.root.navTabs` resolved undefined despite a populated state.js.
|
|
372
|
+
// Explicit non-empty objects + non-root elements bypass this rewrite.
|
|
373
|
+
const isRootCandidate = !parent
|
|
374
|
+
const isEmptyObjectState = definition.state &&
|
|
375
|
+
typeof definition.state === 'object' &&
|
|
376
|
+
!Array.isArray(definition.state) &&
|
|
377
|
+
Object.keys(definition.state).length === 0
|
|
378
|
+
const useContextStateFallback = isRootCandidate && isEmptyObjectState && element.context?.state
|
|
379
|
+
|
|
380
|
+
const stateDef = (definition.state !== undefined && !useContextStateFallback)
|
|
320
381
|
? definition.state
|
|
321
382
|
: (element.state && !isStore(element.state) ? element.state : undefined)
|
|
322
383
|
|
|
@@ -444,9 +505,75 @@ function resolveStatePath(path, parent) {
|
|
|
444
505
|
|
|
445
506
|
// --- Reactive Effects ---
|
|
446
507
|
|
|
508
|
+
function wrapCustomPropFunctions(element) {
|
|
509
|
+
const ref = element.__ref
|
|
510
|
+
const tag = element.tag || 'div'
|
|
511
|
+
const ds = element.context?.designSystem
|
|
512
|
+
|
|
513
|
+
// Two-pass: collect first, install second. Lets sibling refs like
|
|
514
|
+
// `a: fn`, `b: (el) => el.a + 1` work regardless of declaration order
|
|
515
|
+
// because every peer is already a value-producing getter once we begin
|
|
516
|
+
// evaluating any of them.
|
|
517
|
+
const targets = []
|
|
518
|
+
const ownProps = Object.keys(element)
|
|
519
|
+
for (let i = 0; i < ownProps.length; i++) {
|
|
520
|
+
const prop = ownProps[i]
|
|
521
|
+
if (typeof element[prop] !== 'function') continue
|
|
522
|
+
if (REGISTRY.has(prop)) continue
|
|
523
|
+
const firstChar = prop.charCodeAt(0)
|
|
524
|
+
if (firstChar >= 65 && firstChar <= 90) continue // child component
|
|
525
|
+
if (isEventHandler(prop)) continue
|
|
526
|
+
if (CSS_PROPS_REGISTRY[prop]) continue // handled by CSS effect loop
|
|
527
|
+
if (DEFAULT_CSS_PROPERTIES_LIST && DEFAULT_CSS_PROPERTIES_LIST.has(prop)) continue
|
|
528
|
+
if (checkAttributeByTagName(tag, prop)) continue // handled by attr effect loop
|
|
529
|
+
targets.push({ prop, fn: element[prop] })
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
for (let i = 0; i < targets.length; i++) {
|
|
533
|
+
const { prop, fn } = targets[i]
|
|
534
|
+
ref.__exec[prop] = fn
|
|
535
|
+
Object.defineProperty(element, prop, {
|
|
536
|
+
get () {
|
|
537
|
+
// Re-run fn on each read. Signal dependency tracking still works:
|
|
538
|
+
// whatever effect is reading el[prop] subscribes to state signals
|
|
539
|
+
// touched inside fn, so it re-fires on state changes. We skip a
|
|
540
|
+
// memoizing layer (createComputed) so fn never sees a peer custom
|
|
541
|
+
// prop in its raw-function form during this pass's eager init.
|
|
542
|
+
if (ds) pushConfig(ds)
|
|
543
|
+
try {
|
|
544
|
+
return fn(element, element.state, element.context)
|
|
545
|
+
} catch (e) {
|
|
546
|
+
return undefined
|
|
547
|
+
} finally {
|
|
548
|
+
if (ds) popConfig()
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
set (val) {
|
|
552
|
+
delete ref.__exec[prop]
|
|
553
|
+
Object.defineProperty(element, prop, {
|
|
554
|
+
value: val, writable: true, configurable: true, enumerable: true
|
|
555
|
+
})
|
|
556
|
+
},
|
|
557
|
+
configurable: true,
|
|
558
|
+
enumerable: true
|
|
559
|
+
})
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
447
563
|
function registerEffects(element, definition) {
|
|
448
564
|
const ref = element.__ref
|
|
449
565
|
|
|
566
|
+
// Custom-prop function reactivity (FRAMEWORK-1).
|
|
567
|
+
// Replace user-defined function-valued props (lowercase, not framework-
|
|
568
|
+
// reserved, not CSS, not HTML attribute, not event/lifecycle) with a getter
|
|
569
|
+
// that calls the function on read. Reads via `el.X` — including
|
|
570
|
+
// `el.parent.X` from descendants — return the evaluated value, and signal
|
|
571
|
+
// dependency tracking propagates through dependent effects (`text:`,
|
|
572
|
+
// `width:`, `href:`, etc.). Without this wrap, dependent effects coerce the
|
|
573
|
+
// un-invoked function via `Function.prototype.toString` and ship the
|
|
574
|
+
// arrow-function source into the DOM (e.g. "(el, s) => 8 / 10").
|
|
575
|
+
wrapCustomPropFunctions(element)
|
|
576
|
+
|
|
450
577
|
// Text effect
|
|
451
578
|
if (typeof element.text === 'function') {
|
|
452
579
|
const fn = element.text
|
|
@@ -590,6 +717,23 @@ function registerEffects(element, definition) {
|
|
|
590
717
|
}, { depth: ref.path.length })
|
|
591
718
|
ref.__effects.push(effect)
|
|
592
719
|
ref.__exec.style = fn
|
|
720
|
+
} else if (element.style && typeof element.style === 'object') {
|
|
721
|
+
// Nested-object style: function values per-key get individual effects.
|
|
722
|
+
// Static keys go through applyStaticMixins → applyStyles at mount.
|
|
723
|
+
// Without this branch, `style: { color: (el, s) => s.theme }` runs
|
|
724
|
+
// once at mount and never re-evaluates, while flat top-level
|
|
725
|
+
// `color: fn` IS reactive — surprising parity gap (FA-L23).
|
|
726
|
+
for (const styleKey in element.style) {
|
|
727
|
+
const styleFn = element.style[styleKey]
|
|
728
|
+
if (typeof styleFn !== 'function') continue
|
|
729
|
+
const effect = createEffect(() => {
|
|
730
|
+
try {
|
|
731
|
+
const val = styleFn(element, element.state, element.context)
|
|
732
|
+
if (element.node) applyStyles(element.node, { [styleKey]: val })
|
|
733
|
+
} catch (e) { /* style fn may fail before state is populated */ }
|
|
734
|
+
}, { depth: ref.path.length })
|
|
735
|
+
ref.__effects.push(effect)
|
|
736
|
+
}
|
|
593
737
|
}
|
|
594
738
|
|
|
595
739
|
// Attribute effects for function-valued attrs (check element.attr which includes extends)
|
|
@@ -617,6 +761,84 @@ function registerEffects(element, definition) {
|
|
|
617
761
|
}
|
|
618
762
|
}
|
|
619
763
|
|
|
764
|
+
// Reactive nested aria.X — `aria: { label: (el, s) => s.title }` re-runs
|
|
765
|
+
// on every state change. Static aria.X values are flattened to top-level
|
|
766
|
+
// attrs by `filterAttributesByTagName` (attrs-in-props/index.js), so we
|
|
767
|
+
// only wire effects for function values here.
|
|
768
|
+
if (element.aria && typeof element.aria === 'object') {
|
|
769
|
+
for (const ariaKey in element.aria) {
|
|
770
|
+
const ariaFn = element.aria[ariaKey]
|
|
771
|
+
if (typeof ariaFn !== 'function') continue
|
|
772
|
+
const attrName = 'aria-' + ariaKey.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
|
|
773
|
+
const effect = createEffect(() => {
|
|
774
|
+
try {
|
|
775
|
+
const val = ariaFn(element, element.state, element.context)
|
|
776
|
+
if (!element.node?.setAttribute) return
|
|
777
|
+
if (val == null || val === false) {
|
|
778
|
+
element.node.removeAttribute(attrName)
|
|
779
|
+
} else if (typeof val !== 'function' && typeof val !== 'object') {
|
|
780
|
+
element.node.setAttribute(attrName, val === true ? '' : val)
|
|
781
|
+
}
|
|
782
|
+
} catch (e) { /* aria fn may fail before state is populated */ }
|
|
783
|
+
}, { depth: ref.path.length })
|
|
784
|
+
ref.__effects.push(effect)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Reactive nested data.X — same shape as aria, kebab-cases the key.
|
|
789
|
+
if (element.data && typeof element.data === 'object') {
|
|
790
|
+
for (const dataKey in element.data) {
|
|
791
|
+
const dataFn = element.data[dataKey]
|
|
792
|
+
if (typeof dataFn !== 'function') continue
|
|
793
|
+
const kebab = dataKey.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
|
|
794
|
+
const attrName = 'data-' + kebab
|
|
795
|
+
const effect = createEffect(() => {
|
|
796
|
+
try {
|
|
797
|
+
const val = dataFn(element, element.state, element.context)
|
|
798
|
+
if (!element.node?.setAttribute) return
|
|
799
|
+
if (val == null || val === false) {
|
|
800
|
+
element.node.removeAttribute(attrName)
|
|
801
|
+
} else if (typeof val !== 'function' && typeof val !== 'object') {
|
|
802
|
+
element.node.setAttribute(attrName, val === true ? '' : val)
|
|
803
|
+
}
|
|
804
|
+
} catch (e) { /* data fn may fail before state is populated */ }
|
|
805
|
+
}, { depth: ref.path.length })
|
|
806
|
+
ref.__effects.push(effect)
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Reactive classlist / class — `class: { active: (el, s) => s.isOpen }`
|
|
811
|
+
// toggles on state change. Static keys are applied at mount by
|
|
812
|
+
// applyStaticMixins; only function-valued keys need effects here.
|
|
813
|
+
const classObj = (element.classlist && typeof element.classlist === 'object' && !Array.isArray(element.classlist))
|
|
814
|
+
? element.classlist
|
|
815
|
+
: (element.class && typeof element.class === 'object' && !Array.isArray(element.class))
|
|
816
|
+
? element.class
|
|
817
|
+
: null
|
|
818
|
+
if (classObj) {
|
|
819
|
+
for (const className in classObj) {
|
|
820
|
+
const classFn = classObj[className]
|
|
821
|
+
if (typeof classFn !== 'function') continue
|
|
822
|
+
const effect = createEffect(() => {
|
|
823
|
+
try {
|
|
824
|
+
const val = classFn(element, element.state, element.context)
|
|
825
|
+
if (!element.node?.classList) return
|
|
826
|
+
// Mirror the static-path logic: `val: 'custom-name'` → toggle that
|
|
827
|
+
// string class on; falsy → toggle base key off; truthy non-string →
|
|
828
|
+
// toggle base key on.
|
|
829
|
+
if (typeof val === 'string') {
|
|
830
|
+
element.node.classList.add(val)
|
|
831
|
+
} else if (val) {
|
|
832
|
+
element.node.classList.add(className)
|
|
833
|
+
} else {
|
|
834
|
+
element.node.classList.remove(className)
|
|
835
|
+
}
|
|
836
|
+
} catch (e) { /* class fn may fail before state is populated */ }
|
|
837
|
+
}, { depth: ref.path.length })
|
|
838
|
+
ref.__effects.push(effect)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
620
842
|
// If condition effect
|
|
621
843
|
if (typeof definition.if === 'function') {
|
|
622
844
|
const fn = definition.if
|
|
@@ -785,10 +1007,17 @@ function applyStaticMixins(element) {
|
|
|
785
1007
|
}
|
|
786
1008
|
}
|
|
787
1009
|
|
|
788
|
-
// Data attributes
|
|
1010
|
+
// Data attributes — same guard shape as the attr loop above. Function-
|
|
1011
|
+
// valued data.X is handled by the reactive-data effect in registerEffects;
|
|
1012
|
+
// writing the function reference into `dataset` here would render as a
|
|
1013
|
+
// stringified function literal in the DOM until something else overwrote
|
|
1014
|
+
// it. Skip null/false/object values too.
|
|
789
1015
|
if (element.data && typeof element.data === 'object') {
|
|
790
1016
|
for (const key in element.data) {
|
|
791
|
-
|
|
1017
|
+
const val = element.data[key]
|
|
1018
|
+
if (typeof val === 'function' || typeof val === 'object') continue
|
|
1019
|
+
if (val == null || val === false) continue
|
|
1020
|
+
if (node.dataset) node.dataset[key] = val === true ? '' : val
|
|
792
1021
|
}
|
|
793
1022
|
}
|
|
794
1023
|
|
|
@@ -826,12 +1055,22 @@ function applyStyles(node, styles) {
|
|
|
826
1055
|
|
|
827
1056
|
try {
|
|
828
1057
|
const str = String(val)
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
1058
|
+
// Reactive writes go through `setProperty` as plain inline (no
|
|
1059
|
+
// priority). Inline already beats class-rule selectors in the cascade
|
|
1060
|
+
// — the smbls atomic CSS engine never emits `!important`, so a plain
|
|
1061
|
+
// inline value wins on its own. The previous `'important'` priority
|
|
1062
|
+
// was a cargo-culted band-aid for an apparent stale-getComputedStyle
|
|
1063
|
+
// observation; in practice it caused CSS transitions on the same
|
|
1064
|
+
// element to leave stuck `Animation` objects (currentTime:0,
|
|
1065
|
+
// fill:'backwards') because Chrome's transition state machine
|
|
1066
|
+
// mishandles a mid-transition switch to/from `!important`.
|
|
1067
|
+
// If a value carries an explicit `!important` suffix from upstream
|
|
1068
|
+
// (rare — design tokens etc.), honor it.
|
|
1069
|
+
const cssProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
|
|
1070
|
+
const trimmed = str.trim()
|
|
1071
|
+
const isImportant = /\s*!important\s*$/.test(trimmed)
|
|
1072
|
+
const cleanVal = trimmed.replace(/\s*!important\s*$/, '')
|
|
1073
|
+
node.style.setProperty(cssProp, cleanVal, isImportant ? 'important' : '')
|
|
835
1074
|
} catch (e) {
|
|
836
1075
|
// Invalid style value
|
|
837
1076
|
}
|
|
@@ -1049,8 +1288,13 @@ function applyCssInProps(element) {
|
|
|
1049
1288
|
cssStyles[prop] = val
|
|
1050
1289
|
hasStyles = true
|
|
1051
1290
|
}
|
|
1052
|
-
} else if (prop.charCodeAt(0) === 45
|
|
1053
|
-
//
|
|
1291
|
+
} else if (prop.charCodeAt(0) === 45) {
|
|
1292
|
+
// FT-FRAMEWORK-4: any dash-prefixed CSS key — both custom properties
|
|
1293
|
+
// (`--var-name`) and vendor-prefixed properties
|
|
1294
|
+
// (`-webkit-text-stroke`, `-moz-osx-font-smoothing`, …) — pass
|
|
1295
|
+
// straight through the atomic CSS engine. Previously only `--`
|
|
1296
|
+
// matched; `-webkit-text-stroke: 1px darkgrey` at the top level was
|
|
1297
|
+
// silently dropped, forcing consumers to wrap it in `style: { ... }`.
|
|
1054
1298
|
const val = element[prop]
|
|
1055
1299
|
if (val != null && typeof val !== 'object') {
|
|
1056
1300
|
cssStyles[prop] = val
|
|
@@ -1208,6 +1452,22 @@ function applyAttrsInProps(element) {
|
|
|
1208
1452
|
const { node } = element
|
|
1209
1453
|
if (!node || !node.setAttribute) return
|
|
1210
1454
|
|
|
1455
|
+
// Apply STATIC nested aria.X / data.X values at mount. Function values
|
|
1456
|
+
// are owned by the reactive aria/data effects in registerEffects — those
|
|
1457
|
+
// re-run on state changes. Without this loop, aria/data static values
|
|
1458
|
+
// declared as nested objects (`aria: { label: 'Search' }`) silently
|
|
1459
|
+
// dropped — `applyStaticMixins` already promoted top-level data.X to
|
|
1460
|
+
// dataset and the parallel aria branch had no equivalent.
|
|
1461
|
+
if (element.aria && typeof element.aria === 'object') {
|
|
1462
|
+
for (const ariaKey in element.aria) {
|
|
1463
|
+
const v = element.aria[ariaKey]
|
|
1464
|
+
if (typeof v === 'function' || typeof v === 'object') continue
|
|
1465
|
+
if (v == null || v === false) continue
|
|
1466
|
+
const attrName = 'aria-' + ariaKey.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
|
|
1467
|
+
node.setAttribute(attrName, v === true ? '' : v)
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1211
1471
|
// Apply attribute transforms (src, href, poster, etc. with file/template resolution)
|
|
1212
1472
|
const transformed = applyAttrTransforms(element)
|
|
1213
1473
|
for (const attr in transformed) {
|
|
@@ -1264,8 +1524,10 @@ function registerEvents(element, options) {
|
|
|
1264
1524
|
|
|
1265
1525
|
let handler = element[key]
|
|
1266
1526
|
|
|
1267
|
-
// String shortcut: onClick: 'fnName' → el.call('fnName', ev)
|
|
1268
|
-
|
|
1527
|
+
// String shortcut: onClick: 'fnName' → el.call('fnName', ev).
|
|
1528
|
+
// Only valid JS identifiers — arbitrary strings like 'not a function' fall
|
|
1529
|
+
// through to the typeof-function check below and are skipped.
|
|
1530
|
+
if (typeof handler === 'string' && /^[A-Za-z_$][\w$]*$/.test(handler)) {
|
|
1269
1531
|
const fnName = handler
|
|
1270
1532
|
handler = function stringHandlerShortcut (ev) {
|
|
1271
1533
|
return element.call(fnName, ev)
|
package/src/extends.js
CHANGED
|
@@ -5,16 +5,33 @@
|
|
|
5
5
|
|
|
6
6
|
const extendsCache = new WeakMap()
|
|
7
7
|
|
|
8
|
+
// FT-FRAMEWORK-EXTEND-CUSTOM: collect entries from BOTH `extends:`
|
|
9
|
+
// (plural) and `extend:` (singular). Previously the resolver did
|
|
10
|
+
// `definition.extends || definition.extend`, silently dropping one when
|
|
11
|
+
// the other was set. childsafetynet's blog-detail pages used both
|
|
12
|
+
// (`extends: 'Page'` for route-mode + `extend: 'BlogArticle'` for the
|
|
13
|
+
// article shell) and only Page resolved — BlogArticle's child keys
|
|
14
|
+
// never reached the render tree.
|
|
15
|
+
function collectExtendsList (definition) {
|
|
16
|
+
const list = []
|
|
17
|
+
if (definition.extends) {
|
|
18
|
+
if (Array.isArray(definition.extends)) list.push(...definition.extends)
|
|
19
|
+
else list.push(definition.extends)
|
|
20
|
+
}
|
|
21
|
+
if (definition.extend) {
|
|
22
|
+
if (Array.isArray(definition.extend)) list.push(...definition.extend)
|
|
23
|
+
else list.push(definition.extend)
|
|
24
|
+
}
|
|
25
|
+
return list
|
|
26
|
+
}
|
|
27
|
+
|
|
8
28
|
export function applyExtends(element, definition, options) {
|
|
9
|
-
|
|
10
|
-
if (!ext) return
|
|
29
|
+
const ext = collectExtendsList(definition)
|
|
30
|
+
if (!ext.length) return
|
|
11
31
|
|
|
12
32
|
const context = element.context || options?.context || {}
|
|
13
33
|
const components = context.components || {}
|
|
14
34
|
|
|
15
|
-
// Normalize to array
|
|
16
|
-
if (!Array.isArray(ext)) ext = [ext]
|
|
17
|
-
|
|
18
35
|
// Resolve extends chain
|
|
19
36
|
for (let i = 0; i < ext.length; i++) {
|
|
20
37
|
const base = resolveExtend(ext[i], components)
|
|
@@ -48,13 +65,14 @@ function mergeBase(element, base, options, visited = new Set()) {
|
|
|
48
65
|
if (visited.has(base)) return
|
|
49
66
|
visited.add(base)
|
|
50
67
|
|
|
51
|
-
// Recursively resolve base's own extends first
|
|
52
|
-
|
|
53
|
-
|
|
68
|
+
// Recursively resolve base's own extends first — combining both
|
|
69
|
+
// `extends:` (plural) and `extend:` (singular). Mirrors the entry-point
|
|
70
|
+
// collectExtendsList logic so chains like Page → BlogArticle → Flex
|
|
71
|
+
// resolve correctly when intermediate components mix the two forms.
|
|
72
|
+
const baseExt = collectExtendsList(base)
|
|
73
|
+
if (baseExt.length) {
|
|
54
74
|
const context = element.context || options?.context || {}
|
|
55
75
|
const components = context.components || {}
|
|
56
|
-
let baseExt = baseExtends
|
|
57
|
-
if (!Array.isArray(baseExt)) baseExt = [baseExt]
|
|
58
76
|
for (let i = 0; i < baseExt.length; i++) {
|
|
59
77
|
const ancestor = resolveExtend(baseExt[i], components)
|
|
60
78
|
if (ancestor) mergeBase(element, ancestor, options, visited)
|
package/src/methods.js
CHANGED
|
@@ -152,10 +152,20 @@ const elementProto = {
|
|
|
152
152
|
parse(exclude) {
|
|
153
153
|
const result = {}
|
|
154
154
|
const excludeSet = exclude ? new Set(exclude) : null
|
|
155
|
+
const exec = this.__ref?.__exec
|
|
155
156
|
for (const key in this) {
|
|
156
157
|
if (key === 'parent' || key === 'node' || key === '__ref') continue
|
|
157
|
-
if (typeof this[key] === 'function') continue
|
|
158
158
|
if (excludeSet?.has(key)) continue
|
|
159
|
+
// Reactive props (text/html/value/style/attr/css/custom-prop fns) are
|
|
160
|
+
// wrapped at create time; the live value is the evaluated result, but
|
|
161
|
+
// ref.__exec[key] holds the original function (or template). Restore it
|
|
162
|
+
// so reset() / parseDeep() reproduce the reactive definition rather
|
|
163
|
+
// than baking in a snapshot value.
|
|
164
|
+
if (exec && exec[key] !== undefined) {
|
|
165
|
+
result[key] = exec[key]
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
if (typeof this[key] === 'function') continue
|
|
159
169
|
result[key] = this[key]
|
|
160
170
|
}
|
|
161
171
|
return result
|
|
@@ -195,7 +205,11 @@ const elementProto = {
|
|
|
195
205
|
setNodeStyles(styles) {
|
|
196
206
|
if (!this.node?.style) return
|
|
197
207
|
for (const prop in styles) {
|
|
198
|
-
|
|
208
|
+
const val = styles[prop]
|
|
209
|
+
if (val == null) continue
|
|
210
|
+
const cssProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
|
|
211
|
+
const str = String(val).replace(/\s*!important\s*$/, '')
|
|
212
|
+
this.node.style.setProperty(cssProp, str, 'important')
|
|
199
213
|
}
|
|
200
214
|
},
|
|
201
215
|
|
package/src/update.js
CHANGED
|
@@ -47,11 +47,19 @@ export function update(params = {}, opts = {}) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
// Update style
|
|
50
|
+
// Update style — mirrors applyStyles in create.js: write via setProperty
|
|
51
|
+
// with `important` priority so cascade resolves to the inline value
|
|
52
|
+
// even when the element carries atomic CSS classes for sibling static
|
|
53
|
+
// props. Plain `node.style[prop] = val` writes don't invalidate the
|
|
54
|
+
// cached cascade when the first-paint resolved to a different value.
|
|
51
55
|
if (params.style && element.node?.style) {
|
|
52
56
|
if (!ref.__exec.style) {
|
|
53
57
|
for (const prop in params.style) {
|
|
54
|
-
|
|
58
|
+
const val = params.style[prop]
|
|
59
|
+
if (val == null) continue
|
|
60
|
+
const cssProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
|
|
61
|
+
const str = String(val).replace(/\s*!important\s*$/, '')
|
|
62
|
+
element.node.style.setProperty(cssProp, str, 'important')
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
if (!element.style) element.style = {}
|