@symbo.ls/element 3.14.0 → 3.14.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@symbo.ls/element",
3
- "version": "3.14.0",
4
- "description": "Core element creation, update, and disposal engine for smbls v4",
3
+ "version": "3.14.1",
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": ["src", "index.js"],
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 v4
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
@@ -155,12 +155,27 @@ export function create(definition, parent, key, options = {}, attachOptions) {
155
155
  if (definition.classlist !== undefined) element.classlist = definition.classlist
156
156
  if (definition.text !== undefined) element.text = definition.text
157
157
  if (definition.html !== undefined) element.html = definition.html
158
+ // The scope chain at any element resolves keys in this order:
159
+ // own (definition.scope) → parent.scope → … → root.scope → context.globalScope
160
+ // Direct reads like `el.scope.LANG_META` walk the prototype chain, so a
161
+ // component that doesn't declare its own scope still sees parent scopes
162
+ // and the project's globalScope as fallbacks. Without `globalScope` at the
163
+ // bottom of the chain, every project pattern that puts shared constants
164
+ // in `globalScope.js` and reads them via `el.scope.X` was silently broken
165
+ // — the user's reactive function got `undefined` and either threw or
166
+ // rendered nothing. The frank-rewriter's `__scope` helper already chains
167
+ // through globalScope; this brings the runtime `el.scope` to parity.
168
+ const ancestorScope = parent?.scope
169
+ const globalScope =
170
+ (element.context && typeof element.context.globalScope === 'object' && element.context.globalScope) || null
171
+ const baseScope = ancestorScope ||
172
+ (globalScope ? Object.create(globalScope) : null)
158
173
  if (definition.scope !== undefined) {
159
- element.scope = parent?.scope
160
- ? Object.assign(Object.create(parent.scope), definition.scope)
174
+ element.scope = baseScope
175
+ ? Object.assign(Object.create(baseScope), definition.scope)
161
176
  : definition.scope
162
- } else if (parent?.scope) {
163
- element.scope = Object.create(parent.scope)
177
+ } else if (baseScope) {
178
+ element.scope = Object.create(baseScope)
164
179
  }
165
180
  if (definition.if !== undefined) element.if = definition.if
166
181
 
@@ -316,7 +331,21 @@ function createElementState(element, parent, definition) {
316
331
  // Dashboard defines `state: { useJson: true }` would lose its state because
317
332
  // the local definition has no `state` field, and we'd silently inherit
318
333
  // parent state instead.
319
- const stateDef = definition.state !== undefined
334
+ // Treat an empty plain-object `state: {}` declaration as "not provided"
335
+ // when this is the root element AND context.state has content. Without
336
+ // this, scaffolded `app.js` files that ship with `state: {}` boilerplate
337
+ // would silently override context.state (loaded from `state.js`) and
338
+ // leave the root state empty at runtime — see chatbot template where
339
+ // `s.root.navTabs` resolved undefined despite a populated state.js.
340
+ // Explicit non-empty objects + non-root elements bypass this rewrite.
341
+ const isRootCandidate = !parent
342
+ const isEmptyObjectState = definition.state &&
343
+ typeof definition.state === 'object' &&
344
+ !Array.isArray(definition.state) &&
345
+ Object.keys(definition.state).length === 0
346
+ const useContextStateFallback = isRootCandidate && isEmptyObjectState && element.context?.state
347
+
348
+ const stateDef = (definition.state !== undefined && !useContextStateFallback)
320
349
  ? definition.state
321
350
  : (element.state && !isStore(element.state) ? element.state : undefined)
322
351
 
@@ -590,6 +619,23 @@ function registerEffects(element, definition) {
590
619
  }, { depth: ref.path.length })
591
620
  ref.__effects.push(effect)
592
621
  ref.__exec.style = fn
622
+ } else if (element.style && typeof element.style === 'object') {
623
+ // Nested-object style: function values per-key get individual effects.
624
+ // Static keys go through applyStaticMixins → applyStyles at mount.
625
+ // Without this branch, `style: { color: (el, s) => s.theme }` runs
626
+ // once at mount and never re-evaluates, while flat top-level
627
+ // `color: fn` IS reactive — surprising parity gap (FA-L23).
628
+ for (const styleKey in element.style) {
629
+ const styleFn = element.style[styleKey]
630
+ if (typeof styleFn !== 'function') continue
631
+ const effect = createEffect(() => {
632
+ try {
633
+ const val = styleFn(element, element.state, element.context)
634
+ if (element.node) applyStyles(element.node, { [styleKey]: val })
635
+ } catch (e) { /* style fn may fail before state is populated */ }
636
+ }, { depth: ref.path.length })
637
+ ref.__effects.push(effect)
638
+ }
593
639
  }
594
640
 
595
641
  // Attribute effects for function-valued attrs (check element.attr which includes extends)
@@ -617,6 +663,84 @@ function registerEffects(element, definition) {
617
663
  }
618
664
  }
619
665
 
666
+ // Reactive nested aria.X — `aria: { label: (el, s) => s.title }` re-runs
667
+ // on every state change. Static aria.X values are flattened to top-level
668
+ // attrs by `filterAttributesByTagName` (attrs-in-props/index.js), so we
669
+ // only wire effects for function values here.
670
+ if (element.aria && typeof element.aria === 'object') {
671
+ for (const ariaKey in element.aria) {
672
+ const ariaFn = element.aria[ariaKey]
673
+ if (typeof ariaFn !== 'function') continue
674
+ const attrName = 'aria-' + ariaKey.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
675
+ const effect = createEffect(() => {
676
+ try {
677
+ const val = ariaFn(element, element.state, element.context)
678
+ if (!element.node?.setAttribute) return
679
+ if (val == null || val === false) {
680
+ element.node.removeAttribute(attrName)
681
+ } else if (typeof val !== 'function' && typeof val !== 'object') {
682
+ element.node.setAttribute(attrName, val === true ? '' : val)
683
+ }
684
+ } catch (e) { /* aria fn may fail before state is populated */ }
685
+ }, { depth: ref.path.length })
686
+ ref.__effects.push(effect)
687
+ }
688
+ }
689
+
690
+ // Reactive nested data.X — same shape as aria, kebab-cases the key.
691
+ if (element.data && typeof element.data === 'object') {
692
+ for (const dataKey in element.data) {
693
+ const dataFn = element.data[dataKey]
694
+ if (typeof dataFn !== 'function') continue
695
+ const kebab = dataKey.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
696
+ const attrName = 'data-' + kebab
697
+ const effect = createEffect(() => {
698
+ try {
699
+ const val = dataFn(element, element.state, element.context)
700
+ if (!element.node?.setAttribute) return
701
+ if (val == null || val === false) {
702
+ element.node.removeAttribute(attrName)
703
+ } else if (typeof val !== 'function' && typeof val !== 'object') {
704
+ element.node.setAttribute(attrName, val === true ? '' : val)
705
+ }
706
+ } catch (e) { /* data fn may fail before state is populated */ }
707
+ }, { depth: ref.path.length })
708
+ ref.__effects.push(effect)
709
+ }
710
+ }
711
+
712
+ // Reactive classlist / class — `class: { active: (el, s) => s.isOpen }`
713
+ // toggles on state change. Static keys are applied at mount by
714
+ // applyStaticMixins; only function-valued keys need effects here.
715
+ const classObj = (element.classlist && typeof element.classlist === 'object' && !Array.isArray(element.classlist))
716
+ ? element.classlist
717
+ : (element.class && typeof element.class === 'object' && !Array.isArray(element.class))
718
+ ? element.class
719
+ : null
720
+ if (classObj) {
721
+ for (const className in classObj) {
722
+ const classFn = classObj[className]
723
+ if (typeof classFn !== 'function') continue
724
+ const effect = createEffect(() => {
725
+ try {
726
+ const val = classFn(element, element.state, element.context)
727
+ if (!element.node?.classList) return
728
+ // Mirror the static-path logic: `val: 'custom-name'` → toggle that
729
+ // string class on; falsy → toggle base key off; truthy non-string →
730
+ // toggle base key on.
731
+ if (typeof val === 'string') {
732
+ element.node.classList.add(val)
733
+ } else if (val) {
734
+ element.node.classList.add(className)
735
+ } else {
736
+ element.node.classList.remove(className)
737
+ }
738
+ } catch (e) { /* class fn may fail before state is populated */ }
739
+ }, { depth: ref.path.length })
740
+ ref.__effects.push(effect)
741
+ }
742
+ }
743
+
620
744
  // If condition effect
621
745
  if (typeof definition.if === 'function') {
622
746
  const fn = definition.if
@@ -785,10 +909,17 @@ function applyStaticMixins(element) {
785
909
  }
786
910
  }
787
911
 
788
- // Data attributes
912
+ // Data attributes — same guard shape as the attr loop above. Function-
913
+ // valued data.X is handled by the reactive-data effect in registerEffects;
914
+ // writing the function reference into `dataset` here would render as a
915
+ // stringified function literal in the DOM until something else overwrote
916
+ // it. Skip null/false/object values too.
789
917
  if (element.data && typeof element.data === 'object') {
790
918
  for (const key in element.data) {
791
- if (node.dataset) node.dataset[key] = element.data[key]
919
+ const val = element.data[key]
920
+ if (typeof val === 'function' || typeof val === 'object') continue
921
+ if (val == null || val === false) continue
922
+ if (node.dataset) node.dataset[key] = val === true ? '' : val
792
923
  }
793
924
  }
794
925
 
@@ -826,12 +957,22 @@ function applyStyles(node, styles) {
826
957
 
827
958
  try {
828
959
  const str = String(val)
829
- if (str.includes('!important')) {
830
- const cssProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
831
- node.style.setProperty(cssProp, str.replace(/\s*!important\s*$/, ''), 'important')
832
- } else {
833
- node.style[prop] = val
834
- }
960
+ // Reactive writes go through `setProperty` as plain inline (no
961
+ // priority). Inline already beats class-rule selectors in the cascade
962
+ // — the smbls atomic CSS engine never emits `!important`, so a plain
963
+ // inline value wins on its own. The previous `'important'` priority
964
+ // was a cargo-culted band-aid for an apparent stale-getComputedStyle
965
+ // observation; in practice it caused CSS transitions on the same
966
+ // element to leave stuck `Animation` objects (currentTime:0,
967
+ // fill:'backwards') because Chrome's transition state machine
968
+ // mishandles a mid-transition switch to/from `!important`.
969
+ // If a value carries an explicit `!important` suffix from upstream
970
+ // (rare — design tokens etc.), honor it.
971
+ const cssProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
972
+ const trimmed = str.trim()
973
+ const isImportant = /\s*!important\s*$/.test(trimmed)
974
+ const cleanVal = trimmed.replace(/\s*!important\s*$/, '')
975
+ node.style.setProperty(cssProp, cleanVal, isImportant ? 'important' : '')
835
976
  } catch (e) {
836
977
  // Invalid style value
837
978
  }
@@ -1208,6 +1349,22 @@ function applyAttrsInProps(element) {
1208
1349
  const { node } = element
1209
1350
  if (!node || !node.setAttribute) return
1210
1351
 
1352
+ // Apply STATIC nested aria.X / data.X values at mount. Function values
1353
+ // are owned by the reactive aria/data effects in registerEffects — those
1354
+ // re-run on state changes. Without this loop, aria/data static values
1355
+ // declared as nested objects (`aria: { label: 'Search' }`) silently
1356
+ // dropped — `applyStaticMixins` already promoted top-level data.X to
1357
+ // dataset and the parallel aria branch had no equivalent.
1358
+ if (element.aria && typeof element.aria === 'object') {
1359
+ for (const ariaKey in element.aria) {
1360
+ const v = element.aria[ariaKey]
1361
+ if (typeof v === 'function' || typeof v === 'object') continue
1362
+ if (v == null || v === false) continue
1363
+ const attrName = 'aria-' + ariaKey.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
1364
+ node.setAttribute(attrName, v === true ? '' : v)
1365
+ }
1366
+ }
1367
+
1211
1368
  // Apply attribute transforms (src, href, poster, etc. with file/template resolution)
1212
1369
  const transformed = applyAttrTransforms(element)
1213
1370
  for (const attr in transformed) {
@@ -1264,8 +1421,10 @@ function registerEvents(element, options) {
1264
1421
 
1265
1422
  let handler = element[key]
1266
1423
 
1267
- // String shortcut: onClick: 'fnName' → el.call('fnName', ev)
1268
- if (typeof handler === 'string') {
1424
+ // String shortcut: onClick: 'fnName' → el.call('fnName', ev).
1425
+ // Only valid JS identifiers — arbitrary strings like 'not a function' fall
1426
+ // through to the typeof-function check below and are skipped.
1427
+ if (typeof handler === 'string' && /^[A-Za-z_$][\w$]*$/.test(handler)) {
1269
1428
  const fnName = handler
1270
1429
  handler = function stringHandlerShortcut (ev) {
1271
1430
  return element.call(fnName, ev)
package/src/methods.js CHANGED
@@ -195,7 +195,11 @@ const elementProto = {
195
195
  setNodeStyles(styles) {
196
196
  if (!this.node?.style) return
197
197
  for (const prop in styles) {
198
- this.node.style[prop] = styles[prop]
198
+ const val = styles[prop]
199
+ if (val == null) continue
200
+ const cssProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
201
+ const str = String(val).replace(/\s*!important\s*$/, '')
202
+ this.node.style.setProperty(cssProp, str, 'important')
199
203
  }
200
204
  },
201
205
 
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
- element.node.style[prop] = params.style[prop]
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 = {}