@symbo.ls/element 3.14.2 → 3.14.4

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/src/create.js DELETED
@@ -1,1967 +0,0 @@
1
- 'use strict'
2
-
3
- import { createStore, isStore } from '@symbo.ls/signal'
4
- import { createEffect, batch } from '@symbo.ls/signal'
5
- import { cacheNode, detectTag } from './cache.js'
6
- import { REGISTRY, LIFECYCLE_EVENTS } from './registry.js'
7
- import { registerEvent } from './delegate.js'
8
- import { applyExtends } from './extends.js'
9
- import { addMethods } from './methods.js'
10
- import { dispose } from './dispose.js'
11
- import { replaceLiteralsWithObjectFields, exec } from '@symbo.ls/utils'
12
- import { runPluginHook } from '@symbo.ls/utils/function.js'
13
- import { applyStateMethods } from '@symbo.ls/state'
14
- import { CSS_PROPS_REGISTRY } from 'css-in-props/src/props'
15
- import { DEFAULT_CSS_PROPERTIES_LIST } from 'css-in-props/src/props/defaults'
16
- import { css as atomicCss } from '@symbo.ls/css'
17
- import { pushConfig, popConfig } from '@symbo.ls/scratch'
18
- import { checkAttributeByTagName, applyAttrTransforms } from 'attrs-in-props'
19
-
20
- let _uniqId = 1
21
-
22
- // Detect onXxx event handler pattern
23
- function isEventHandler(key) {
24
- return key.length > 2 &&
25
- key.charCodeAt(0) === 111 && // 'o'
26
- key.charCodeAt(1) === 110 && // 'n'
27
- key.charCodeAt(2) >= 65 && key.charCodeAt(2) <= 90 // A-Z
28
- }
29
-
30
- // Extract event name from onXxx key (onClick -> click, onMouseDown -> mouseDown)
31
- function eventNameFrom(key) {
32
- return key.charAt(2).toLowerCase() + key.slice(3)
33
- }
34
-
35
- // Convert camelCase event name to lowercase DOM event (mouseDown -> mousedown)
36
- function toDomEventName(name) {
37
- return name.toLowerCase()
38
- }
39
-
40
- // Deep merge for child component definitions — override wins, but nested objects are recursively merged
41
- function deepMergeChild(base, override) {
42
- const result = { ...base }
43
- for (const key in override) {
44
- const baseVal = result[key]
45
- const overVal = override[key]
46
- if (baseVal && overVal &&
47
- typeof baseVal === 'object' && typeof overVal === 'object' &&
48
- !Array.isArray(baseVal) && !Array.isArray(overVal) &&
49
- typeof baseVal !== 'function' && typeof overVal !== 'function') {
50
- result[key] = deepMergeChild(baseVal, overVal)
51
- } else {
52
- result[key] = overVal
53
- }
54
- }
55
- return result
56
- }
57
-
58
- // --- Create Element ---
59
-
60
- export function create(definition, parent, key, options = {}, attachOptions) {
61
- // Normalize definition
62
- if (definition == null || definition === false) return null
63
- if (typeof definition === 'string' || typeof definition === 'number') {
64
- definition = { text: definition }
65
- }
66
-
67
-
68
- const element = Object.create(null)
69
- element.key = key || definition.key || `_${_uniqId++}`
70
- element.parent = parent || null
71
- element.context = definition.context || parent?.context || options.context || {}
72
- if (!element.context.__create) element.context.__create = create
73
-
74
- // Internal ref
75
- const ref = element.__ref = {
76
- __if: true,
77
- __children: [],
78
- __exec: {},
79
- __effects: [],
80
- __eventCleanup: [],
81
- root: parent?.__ref?.root || element,
82
- path: parent?.__ref?.path ? [...parent.__ref.path, element.key] : [element.key]
83
- }
84
-
85
- // Auto-extend from key name: Key_suffix -> extends Key component
86
- if (key && !definition.extends && !definition.extend) {
87
- const underscoreIdx = key.indexOf('_')
88
- if (underscoreIdx > 0) {
89
- const baseName = key.substring(0, underscoreIdx)
90
- const components = element.context?.components
91
- if (components && (components[baseName] || components['smbls.' + baseName])) {
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
- )
109
- }
110
- }
111
- }
112
-
113
- // Merge extends chain
114
- applyExtends(element, definition, options)
115
-
116
- // Copy properties from definition — deep merge child overrides, shallow merge known keys, override rest
117
- const MERGE_KEYS = new Set(['childProps', 'attr', 'style', 'scope', 'data'])
118
-
119
- // Capture props set by the extends chain before they get merged flat
120
- const inheritedProps = (element.props && typeof element.props === 'object')
121
- ? element.props
122
- : null
123
-
124
- for (const prop in definition) {
125
- if (prop === 'parent' || prop === 'node' || prop === '__ref' || prop === 'extends' || prop === 'context' || prop === 'props') continue
126
- const val = definition[prop]
127
- const existing = element[prop]
128
- const bothObjects = existing && val &&
129
- typeof existing === 'object' && typeof val === 'object' &&
130
- !Array.isArray(existing) && !Array.isArray(val) &&
131
- typeof existing !== 'function' && typeof val !== 'function'
132
- if (bothObjects && (prop.charCodeAt(0) >= 65 && prop.charCodeAt(0) <= 90)) {
133
- // Deep merge for uppercase child component overrides (definition wins)
134
- element[prop] = deepMergeChild(existing, val)
135
- } else if (bothObjects && MERGE_KEYS.has(prop)) {
136
- element[prop] = { ...existing, ...val }
137
- } else {
138
- element[prop] = val
139
- }
140
- }
141
-
142
- // Helper to merge a props-like object onto element directly
143
- function mergePropsOntoElement (propsObj) {
144
- for (const prop in propsObj) {
145
- const val = propsObj[prop]
146
- const existing = element[prop]
147
- const bothObjects = existing && val &&
148
- typeof existing === 'object' && typeof val === 'object' &&
149
- !Array.isArray(existing) && !Array.isArray(val) &&
150
- typeof existing !== 'function' && typeof val !== 'function'
151
- if (bothObjects && (prop.charCodeAt(0) >= 65 && prop.charCodeAt(0) <= 90)) {
152
- element[prop] = deepMergeChild(existing, val)
153
- } else if (bothObjects && MERGE_KEYS.has(prop)) {
154
- element[prop] = { ...existing, ...val }
155
- } else {
156
- element[prop] = val
157
- }
158
- }
159
- }
160
-
161
- // Merge inherited props from extends chain flat onto element
162
- if (inheritedProps) mergePropsOntoElement(inheritedProps)
163
-
164
- // Merge definition.props flat onto element (definition overrides inherited)
165
- if (definition.props && typeof definition.props === 'object') {
166
- mergePropsOntoElement(definition.props)
167
- }
168
-
169
- if (definition.data !== undefined) element.data = definition.data
170
-
171
- if (definition.classlist !== undefined) element.classlist = definition.classlist
172
- if (definition.text !== undefined) element.text = definition.text
173
- if (definition.html !== undefined) element.html = definition.html
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)
202
- }
203
- if (definition.if !== undefined) element.if = definition.if
204
-
205
-
206
- // Add methods
207
- addMethods(element)
208
-
209
- // Create state (reactive store)
210
- createElementState(element, parent, definition)
211
-
212
- // Evaluate `if` condition
213
- if (element.if != null) {
214
- const ifResult = typeof element.if === 'function'
215
- ? element.if(element, element.state, element.context)
216
- : element.if
217
- ref.__if = !!ifResult
218
- }
219
-
220
- if (!ref.__if) {
221
- if (parent) parent[element.key] = element
222
- // Register only the `if` effect so the element can appear later when the
223
- // condition becomes true. Other effects will be registered inside the
224
- // effect's first-time activation branch.
225
- if (typeof definition.if === 'function') {
226
- registerEffects(element, { if: definition.if })
227
- }
228
- return element
229
- } else if (typeof definition.if === 'function') {
230
- // Element starts visible — mark as initialized so the `if` effect's
231
- // re-activation branch (false→true) doesn't re-run full creation.
232
- ref.__ifInitialized = true
233
- }
234
-
235
- // Detect tag
236
- if (!element.tag) element.tag = detectTag(element)
237
-
238
- // Fire onInit
239
- triggerLifecycle('Init', element, options)
240
-
241
- // Create DOM node
242
- const node = element.node = cacheNode(element)
243
- if (!node) return element
244
-
245
- triggerLifecycle('AttachNode', element, options)
246
-
247
- // Set key as data attribute for dev debugging
248
- if (node.setAttribute && element.key) {
249
- node.setAttribute('data-key', element.key)
250
- }
251
-
252
- // Stamp data-smbls-app on the app's root node so its design-system CSS
253
- // variables (scoped to `[data-smbls-app="<key>"]`) cascade through its
254
- // subtree without leaking to sibling apps in the same document.
255
- if (
256
- node.setAttribute &&
257
- element.context?.scopeSelector &&
258
- element.context.scopeSelector !== ':root' &&
259
- element.context.key &&
260
- element.key === element.context.key
261
- ) {
262
- node.setAttribute('data-smbls-app', element.context.key)
263
- }
264
-
265
- // Apply id attribute
266
- if (element.id && node.setAttribute) {
267
- node.id = element.id
268
- }
269
-
270
- // Register reactive effects for dynamic properties
271
- registerEffects(element, definition)
272
-
273
- // Apply static mixins (text, html, style, attr, data, classlist)
274
- applyStaticMixins(element)
275
-
276
- // Apply attrs-in-props (src, href, etc. from element properties)
277
- applyAttrsInProps(element)
278
-
279
- // Register events (onXxx handlers directly on element)
280
- registerEvents(element, options)
281
-
282
- // FB-render-runtime: previously a thrown error inside createChildren()
283
- // would silently abort the element-mount walk, leaving the page with a
284
- // 0-child root div and no console output. Wrap with try/catch + log so
285
- // operators can see what crashed.
286
- try {
287
- createChildren(element, options)
288
- } catch (childErr) {
289
- if (typeof console !== 'undefined' && console.error) {
290
- const elPath = element.__ref?.path?.join('.') || element.key || '<root>'
291
- console.error(
292
- `[smbls] createChildren failed at ${elPath}:`,
293
- childErr?.message || childErr,
294
- childErr?.stack ? '\n' + childErr.stack.split('\n').slice(0, 6).join('\n') : ''
295
- )
296
- }
297
- }
298
-
299
- // Assign node to DOM
300
- assignNode(element, parent, attachOptions)
301
-
302
- // Track in parent
303
- if (parent) {
304
- parent[element.key] = element
305
- if (parent.__ref?.__children) {
306
- if (!parent.__ref.__children.includes(element.key)) {
307
- parent.__ref.__children.push(element.key)
308
- }
309
- }
310
- }
311
-
312
- // Evaluate extends-inherited html functions (e.g. Svg component).
313
- // Guard: if the function returns a function (e.g. an extended component
314
- // forwards `value` which itself is a function getter), skip setting
315
- // innerHTML — otherwise we'd serialize the function source into the DOM
316
- // (visible as `(el, s) => …` text inside textareas / inputs).
317
- if (typeof element.html === 'function' && element.node && !element.node.innerHTML) {
318
- try {
319
- const val = element.html(element, element.state, element.context)
320
- if (val != null && typeof val !== 'function') element.node.innerHTML = val
321
- } catch (e) { /* skip if html function errors */ }
322
- }
323
-
324
- triggerLifecycle('Create', element, options)
325
- triggerLifecycle('Complete', element, options)
326
- triggerLifecycle('Render', element, options)
327
- triggerLifecycle('RenderRouter', element, options)
328
-
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`).
337
- if (typeof element.onFrame === 'function') {
338
- const frameHandler = element.onFrame
339
- const frameDsConfig = element.context?.designSystem
340
- const tick = () => {
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
- }
348
- }
349
- requestAnimationFrame(tick)
350
- }
351
- requestAnimationFrame(tick)
352
- }
353
-
354
- return element
355
- }
356
-
357
- // --- State Creation ---
358
-
359
- function createElementState(element, parent, definition) {
360
- // Prefer the local definition's `state`, but fall back to anything that
361
- // applyExtends already merged onto element.state from the extends chain.
362
- // Without this fallback, a component like `{ extends: 'Dashboard' }` where
363
- // Dashboard defines `state: { useJson: true }` would lose its state because
364
- // the local definition has no `state` field, and we'd silently inherit
365
- // parent state instead.
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)
381
- ? definition.state
382
- : (element.state && !isStore(element.state) ? element.state : undefined)
383
-
384
- if (stateDef === undefined || stateDef === null) {
385
- if (parent?.state) {
386
- element.state = parent.state
387
- applyStateMethods(element, element.state)
388
- } else if (element.context?.state) {
389
- // Root element: use prepared state from context
390
- const ctxState = element.context.state
391
- const stateData = { ...ctxState }
392
-
393
- // Merge polyglot translations for active language into root state
394
- const poly = element.context?.polyglot
395
- if (poly?.translations) {
396
- const lang = ctxState.lang || poly.defaultLang || 'en'
397
- const langT = poly.translations[lang]
398
- if (langT && typeof langT === 'object') {
399
- for (const k in langT) {
400
- if (stateData[k] === undefined) stateData[k] = langT[k]
401
- }
402
- }
403
- // Also merge top-level translation keys (skip language code keys)
404
- const langs = new Set(poly.languages || [])
405
- for (const k in poly.translations) {
406
- if (!langs.has(k) && stateData[k] === undefined) {
407
- stateData[k] = poly.translations[k]
408
- }
409
- }
410
- }
411
-
412
- element.state = isStore(stateData) ? stateData : createStore(stateData)
413
- element.state.__element = element
414
- element.state.root = element.state
415
- applyStateMethods(element, element.state)
416
- } else {
417
- element.state = {}
418
- // Self-reference root so consumer code can rely on `s.root` always
419
- // being defined, matching the root-element branch above. Non-enumerable
420
- // so Object.keys / for…in / JSON.stringify skip it (this is a plain
421
- // object — unlike a store proxy, which hides root from ownKeys via
422
- // the proxy trap).
423
- Object.defineProperty(element.state, 'root', {
424
- value: element.state, enumerable: false, configurable: true, writable: true
425
- })
426
- applyStateMethods(element, element.state)
427
- }
428
- return
429
- }
430
-
431
- if (typeof stateDef === 'string') {
432
- element.state = resolveStatePath(stateDef, parent)
433
- // Propagate root reference to plain sub-state objects (v3 compat: s.root).
434
- // Final fallback to self-ref keeps `s.root` defined even for orphan
435
- // string-bound state (no parent, or parent has no state).
436
- if (element.state && typeof element.state === 'object' && !isStore(element.state)) {
437
- const rootRef = parent?.state?.root || parent?.state || element.state
438
- Object.defineProperty(element.state, 'root', {
439
- value: rootRef, enumerable: false, configurable: true, writable: true
440
- })
441
- }
442
- applyStateMethods(element, element.state)
443
- return
444
- }
445
-
446
- if (typeof stateDef === 'function') {
447
- const resolved = stateDef(element, parent?.state)
448
- element.state = createStore(resolved || {})
449
- } else {
450
- element.state = createStore({ ...stateDef })
451
- }
452
-
453
- element.state.__element = element
454
- if (parent?.state) {
455
- element.state.parent = parent.state
456
- // Inherit root reference from parent chain
457
- element.state.root = parent.state.root || parent.state
458
- } else {
459
- // Root element: self-reference for v3 compat (state.root === state)
460
- element.state.root = element.state
461
- }
462
- applyStateMethods(element, element.state)
463
- }
464
-
465
- function resolvePath(obj, path) {
466
- if (!path) return obj
467
- const parts = path.split('/')
468
- let result = obj
469
- for (const part of parts) {
470
- if (result === undefined || result === null) return {}
471
- result = result[part]
472
- }
473
- return result ?? {}
474
- }
475
-
476
- function resolveStatePath(path, parent) {
477
- if (!parent) return {}
478
- if (path === '.') return parent.state || {}
479
- if (path.startsWith('../')) {
480
- const key = path.slice(3)
481
- return key ? resolvePath(parent.state, key) : parent.state ?? {}
482
- }
483
- if (path.startsWith('~/')) {
484
- const rootState = parent.state?.root || parent.state
485
- const key = path.slice(2)
486
- return key ? resolvePath(rootState, key) : rootState ?? {}
487
- }
488
- // Check parent state first (supports nested paths like 'product/0')
489
- if (parent.state?.[path] !== undefined) return parent.state[path]
490
- const nested = resolvePath(parent.state, path)
491
- if (nested !== undefined && nested !== null && Object.keys(nested).length > 0) return nested
492
-
493
- // Fall back to polyglot translations for current language
494
- const poly = parent.context?.polyglot
495
- if (poly?.translations) {
496
- const lang = parent.state?.root?.lang || poly.defaultLang || 'en'
497
- const langTranslations = poly.translations[lang]
498
- if (langTranslations?.[path] !== undefined) return langTranslations[path]
499
- // Check top-level translations
500
- if (poly.translations[path] !== undefined) return poly.translations[path]
501
- }
502
-
503
- return {}
504
- }
505
-
506
- // --- Reactive Effects ---
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
-
563
- function registerEffects(element, definition) {
564
- const ref = element.__ref
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
-
577
- // Text effect
578
- if (typeof element.text === 'function') {
579
- const fn = element.text
580
- const effect = createEffect(() => {
581
- try {
582
- let val = fn(element, element.state, element.context)
583
- if (element.node && val != null && typeof val !== 'function' && typeof val !== 'object') {
584
- // Resolve {{ }} templates in function return values
585
- if (typeof val === 'string' && val.includes('{{')) {
586
- val = replaceLiteralsWithObjectFields.call(element, val, element.state)
587
- }
588
- if (element.tag === 'string') {
589
- element.node.nodeValue = val
590
- } else {
591
- element.node.textContent = val
592
- }
593
- }
594
- } catch (e) { /* text effect may fail before state is populated */ }
595
- }, { depth: ref.path.length })
596
- ref.__effects.push(effect)
597
- ref.__exec.text = fn
598
- } else if (typeof element.text === 'string' && element.text.includes('{{')) {
599
- const template = element.text
600
- const effect = createEffect(() => {
601
- const val = replaceLiteralsWithObjectFields.call(element, template, element.state)
602
- if (val != null) element.text = val // Update element.text with resolved value
603
- if (element.node) {
604
- if (element.tag === 'string') {
605
- element.node.nodeValue = val ?? ''
606
- } else {
607
- element.node.textContent = val ?? ''
608
- }
609
- }
610
- }, { depth: ref.path.length })
611
- ref.__effects.push(effect)
612
- ref.__exec.text = template
613
- }
614
-
615
- // Reactive effects for function-valued CSS properties (CSS_PROPS_REGISTRY and DEFAULT_CSS)
616
- for (const prop in element) {
617
- if (!Object.prototype.hasOwnProperty.call(element, prop)) continue
618
- if (typeof element[prop] !== 'function') continue
619
- if (REGISTRY.has(prop)) continue
620
- if (isEventHandler(prop)) continue
621
- if (ref.__exec[prop]) continue
622
-
623
- if (CSS_PROPS_REGISTRY[prop]) {
624
- const fn = element[prop]
625
- const cssProp = prop
626
- const effect = createEffect(() => {
627
- // Push this element's design system config so CSS_PROPS_REGISTRY
628
- // handlers (which internally call getActiveConfig() for token resolution)
629
- // see the right config in multi-app setups (canvas editor vs. iframe project).
630
- // The document is on the config, so getActiveDocument() resolves it too.
631
- const ds = element.context?.designSystem
632
- if (ds) pushConfig(ds)
633
- try {
634
- const val = fn(element, element.state, element.context)
635
- const cssResult = CSS_PROPS_REGISTRY[cssProp](val, element, element.state, element.context)
636
- if (cssResult && element.node) {
637
- applyStyles(element.node, cssResult)
638
- } else if (element.node && (cssProp === 'show' || cssProp === 'hide')) {
639
- element.node.style.display = ''
640
- }
641
- } finally {
642
- if (ds) popConfig()
643
- }
644
- }, { depth: ref.path.length })
645
- ref.__effects.push(effect)
646
- ref.__exec[prop] = fn
647
- } else if (DEFAULT_CSS_PROPERTIES_LIST && DEFAULT_CSS_PROPERTIES_LIST.has(prop)) {
648
- const fn = element[prop]
649
- const cssProp = prop
650
- const effect = createEffect(() => {
651
- const ds = element.context?.designSystem
652
- if (ds) pushConfig(ds)
653
- try {
654
- const val = fn(element, element.state, element.context)
655
- if (element.node) {
656
- if (val != null) applyStyles(element.node, { [cssProp]: val })
657
- else element.node.style[cssProp] = ''
658
- }
659
- } finally {
660
- if (ds) popConfig()
661
- }
662
- }, { depth: ref.path.length })
663
- ref.__effects.push(effect)
664
- ref.__exec[prop] = fn
665
- }
666
- }
667
-
668
-
669
- // HTML effect.
670
- // Guard: skip when the html function returns a function (forwarded reactive
671
- // getter from an extended component). Avoids serializing the function source
672
- // into innerHTML — see same guard in the eager-pass block above.
673
- if (typeof definition.html === 'function') {
674
- const fn = definition.html
675
- const effect = createEffect(() => {
676
- const val = fn(element, element.state, element.context)
677
- if (element.node && val != null && typeof val !== 'function') {
678
- element.node.innerHTML = val
679
- }
680
- }, { depth: ref.path.length })
681
- ref.__effects.push(effect)
682
- ref.__exec.html = fn
683
- }
684
-
685
- // Value effect — reactively set node.value on input/textarea/select.
686
- // Preserves cursor position for the currently focused input so a re-render
687
- // mid-typing does not yank the caret to the end.
688
- if (typeof definition.value === 'function') {
689
- const fn = definition.value
690
- const effect = createEffect(() => {
691
- try {
692
- const val = fn(element, element.state, element.context)
693
- if (!element.node || !('value' in element.node)) return
694
- const next = (val == null) ? '' : String(val)
695
- if (element.node.value === next) return
696
- const focused = (typeof document !== 'undefined') && document.activeElement === element.node
697
- const selStart = focused && typeof element.node.selectionStart === 'number'
698
- ? element.node.selectionStart : null
699
- const selEnd = focused && typeof element.node.selectionEnd === 'number'
700
- ? element.node.selectionEnd : null
701
- element.node.value = next
702
- if (focused && selStart != null && selEnd != null) {
703
- try { element.node.setSelectionRange(selStart, selEnd) } catch (e) {}
704
- }
705
- } catch (e) { /* value effect may fail before state is populated */ }
706
- }, { depth: ref.path.length })
707
- ref.__effects.push(effect)
708
- ref.__exec.value = fn
709
- }
710
-
711
- // Style effect
712
- if (typeof definition.style === 'function') {
713
- const fn = definition.style
714
- const effect = createEffect(() => {
715
- const styles = fn(element, element.state, element.context)
716
- applyStyles(element.node, styles)
717
- }, { depth: ref.path.length })
718
- ref.__effects.push(effect)
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
- }
737
- }
738
-
739
- // Attribute effects for function-valued attrs (check element.attr which includes extends)
740
- if (element.attr && typeof element.attr === 'object') {
741
- for (const attrName in element.attr) {
742
- if (typeof element.attr[attrName] === 'function') {
743
- const fn = element.attr[attrName]
744
- const name = attrName
745
- const effect = createEffect(() => {
746
- try {
747
- const val = fn(element, element.state, element.context)
748
- if (element.node?.setAttribute) {
749
- if (val == null || val === false) {
750
- element.node.removeAttribute(name)
751
- } else if (typeof val !== 'function' && typeof val !== 'object') {
752
- element.node.setAttribute(name, val === true ? '' : val)
753
- }
754
- }
755
- } catch (e) {
756
- // Attr function may fail if element properties aren't ready yet
757
- }
758
- }, { depth: ref.path.length })
759
- ref.__effects.push(effect)
760
- }
761
- }
762
- }
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
-
842
- // If condition effect
843
- if (typeof definition.if === 'function') {
844
- const fn = definition.if
845
- const effect = createEffect(() => {
846
- const ifResult = fn(element, element.state, element.context)
847
- const wasVisible = ref.__if
848
- ref.__if = !!ifResult
849
-
850
- if (wasVisible && !ref.__if && element.node) {
851
- element.node.remove()
852
- } else if (!wasVisible && ref.__if) {
853
- // First-time activation: run the full creation steps that were skipped
854
- // when initial `if` was false. This builds the element's node, children,
855
- // attrs, and events the first time the condition becomes true.
856
- if (!ref.__ifInitialized) {
857
- ref.__ifInitialized = true
858
- if (!element.tag) element.tag = detectTag(element)
859
- triggerLifecycle('Init', element, {})
860
- if (!element.node) element.node = cacheNode(element)
861
- if (!element.node) return
862
- triggerLifecycle('AttachNode', element, {})
863
- if (element.node.setAttribute && element.key) {
864
- element.node.setAttribute('data-key', element.key)
865
- }
866
- if (element.id && element.node.setAttribute) {
867
- element.node.id = element.id
868
- }
869
- // Register reactive effects for dynamic props (excluding `if` to avoid recursion)
870
- const prevIf = definition.if
871
- delete definition.if
872
- try { registerEffects(element, definition) } finally { definition.if = prevIf }
873
- applyStaticMixins(element)
874
- applyAttrsInProps(element)
875
- registerEvents(element, {})
876
- createChildren(element, {})
877
- assignNode(element, element.parent)
878
- } else {
879
- // Subsequent re-activations: just re-attach existing node
880
- if (!element.node) element.node = cacheNode(element)
881
- // Find the next sibling in DOMQL tree that has a node, to insert before it
882
- const parent = element.parent
883
- const parentNode = parent?.node
884
- if (parentNode && element.node) {
885
- let nextSibNode = null
886
- const keys = Object.keys(parent)
887
- const myIdx = keys.indexOf(element.key)
888
- for (let i = myIdx + 1; i < keys.length; i++) {
889
- const sib = parent[keys[i]]
890
- if (sib?.node?.parentNode === parentNode) {
891
- nextSibNode = sib.node
892
- break
893
- }
894
- }
895
- if (nextSibNode) {
896
- parentNode.insertBefore(element.node, nextSibNode)
897
- } else {
898
- parentNode.appendChild(element.node)
899
- }
900
- } else {
901
- assignNode(element, element.parent)
902
- }
903
- applyStaticMixins(element)
904
- }
905
- }
906
- }, { depth: ref.path.length })
907
- ref.__effects.push(effect)
908
- }
909
-
910
- // CSS-in-props effects (dynamic CSS properties as functions)
911
- for (const prop in element) {
912
- if (!Object.prototype.hasOwnProperty.call(element, prop)) continue
913
- if (REGISTRY.has(prop)) continue
914
- if (typeof element[prop] !== 'function') continue
915
-
916
- // Skip child components (Uppercase)
917
- const firstChar = prop.charCodeAt(0)
918
- if (firstChar >= 65 && firstChar <= 90) continue
919
-
920
- // Skip onXxx event handlers
921
- if (isEventHandler(prop)) continue
922
-
923
- const fn = element[prop]
924
- ref.__exec[prop] = fn
925
-
926
- // Reactive effects for function-valued HTML attributes (src, href, etc.)
927
- // Skip props already handled as CSS properties
928
- const tag = element.tag || 'div'
929
- if (checkAttributeByTagName(tag, prop) && !CSS_PROPS_REGISTRY[prop] && !(DEFAULT_CSS_PROPERTIES_LIST && DEFAULT_CSS_PROPERTIES_LIST.has(prop))) {
930
- const attrName = prop
931
- const effect = createEffect(() => {
932
- try {
933
- const val = fn(element, element.state, element.context)
934
- if (element.node?.setAttribute) {
935
- if (val == null || val === false) {
936
- element.node.removeAttribute(attrName)
937
- } else if (typeof val !== 'function' && typeof val !== 'object') {
938
- // Use property setter for src on iframes/media to trigger navigation
939
- if (attrName === 'src' && 'src' in element.node) {
940
- element.node.src = val
941
- } else {
942
- element.node.setAttribute(attrName, val === true ? '' : val)
943
- }
944
- }
945
- }
946
- } catch (e) { /* attr function may fail before state is populated */ }
947
- }, { depth: ref.path.length })
948
- ref.__effects.push(effect)
949
- }
950
- }
951
-
952
- // Reactive conditionals (.prop / !prop blocks)
953
- registerConditionalEffects(element)
954
- }
955
-
956
- // --- Static Mixin Application ---
957
-
958
- function applyStaticMixins(element) {
959
- const { node } = element
960
- if (!node) return
961
-
962
- // Text (skip template literals — handled by reactive effect)
963
- if (typeof element.text === 'string' && !element.text.includes('{{')) {
964
- if (element.tag === 'string') {
965
- node.nodeValue = element.text
966
- } else {
967
- node.textContent = element.text
968
- }
969
- } else if (typeof element.text === 'number') {
970
- node.textContent = element.text
971
- }
972
-
973
- // CSS-in-props static transforms (show, hide, padding, flexAlign, etc.)
974
- // Conditionals (.prop / !prop) are handled reactively in registerConditionalEffects
975
- applyCssInProps(element)
976
-
977
- // HTML
978
- if (typeof element.html === 'string') {
979
- node.innerHTML = element.html
980
- }
981
-
982
- // Static value for input/textarea/select. The reactive effect path
983
- // (see registerEffects) handles function-valued `value:` props.
984
- if (
985
- element.value != null &&
986
- typeof element.value !== 'function' &&
987
- 'value' in node
988
- ) {
989
- const next = String(element.value)
990
- if (node.value !== next) node.value = next
991
- }
992
-
993
- // Style
994
- if (element.style && typeof element.style === 'object') {
995
- applyStyles(node, element.style)
996
- }
997
-
998
- // Attributes
999
- if (element.attr && typeof element.attr === 'object') {
1000
- for (const key in element.attr) {
1001
- const val = element.attr[key]
1002
- if (typeof val === 'function' || typeof val === 'object') continue
1003
- if (val == null || val === false) continue
1004
- if (node.setAttribute) {
1005
- node.setAttribute(key, val === true ? '' : val)
1006
- }
1007
- }
1008
- }
1009
-
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.
1015
- if (element.data && typeof element.data === 'object') {
1016
- for (const key in element.data) {
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
1021
- }
1022
- }
1023
-
1024
- // Classlist
1025
- if (element.classlist || element.class) {
1026
- const cls = element.classlist || element.class
1027
- if (typeof cls === 'string') {
1028
- cls.split(' ').forEach(c => c && node.classList.add(c))
1029
- } else if (typeof cls === 'object') {
1030
- for (const name in cls) {
1031
- const val = typeof cls[name] === 'function'
1032
- ? cls[name](element, element.state, element.context)
1033
- : cls[name]
1034
- if (val) node.classList.add(typeof val === 'string' ? val : name)
1035
- }
1036
- }
1037
- }
1038
- }
1039
-
1040
- function applyStyles(node, styles) {
1041
- if (!node || !styles || !node.style) return
1042
-
1043
- let cssRules = null
1044
-
1045
- for (const prop in styles) {
1046
- const val = styles[prop]
1047
- if (val == null) continue
1048
-
1049
- // Handle &: selectors — generate CSS rules
1050
- if (prop.charAt(0) === '&' && typeof val === 'object') {
1051
- if (!cssRules) cssRules = {}
1052
- cssRules[prop] = val
1053
- continue
1054
- }
1055
-
1056
- try {
1057
- const str = String(val)
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' : '')
1074
- } catch (e) {
1075
- // Invalid style value
1076
- }
1077
- }
1078
-
1079
- // Inject CSS rules for &: selectors
1080
- if (cssRules) {
1081
- injectScopedCSS(node, cssRules)
1082
- }
1083
- }
1084
-
1085
- let _scopedStyleId = 0
1086
-
1087
- function injectScopedCSS(node, rules) {
1088
- const id = `_ds${_scopedStyleId++}`
1089
- node.classList.add(id)
1090
-
1091
- const doc = node.ownerDocument
1092
- let styleEl = doc.querySelector('style[data-smbls-scoped]')
1093
- if (!styleEl) {
1094
- styleEl = doc.createElement('style')
1095
- styleEl.setAttribute('data-smbls-scoped', '')
1096
- doc.head.appendChild(styleEl)
1097
- }
1098
-
1099
- let css = ''
1100
- for (const selector in rules) {
1101
- // Replace & with scoped class, and auto-scope unscoped comma-separated parts
1102
- const scopedClass = '.' + id
1103
- const resolvedSelector = selector.replace(/&/g, scopedClass)
1104
- .split(',')
1105
- .map(s => {
1106
- const t = s.trim()
1107
- return t.includes(scopedClass) ? t : scopedClass + t
1108
- })
1109
- .join(', ')
1110
- const val = rules[selector]
1111
-
1112
- if (typeof val === 'object') {
1113
- // Check for nested selectors (e.g. '& [dropdown]' inside '&:focus-within')
1114
- let props = ''
1115
- let nested = ''
1116
- for (const k in val) {
1117
- const v = val[k]
1118
- if (typeof v === 'object') {
1119
- const nestedSelector = k.replace(/&/g, '.' + id)
1120
- const nestedSuffix = ' ' + nestedSelector.replace('.' + id, '')
1121
- const fullSelector = resolvedSelector.split(', ').map(s => s + nestedSuffix).join(', ')
1122
- let nestedProps = ''
1123
- for (const nk in v) {
1124
- nestedProps += `${nk.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}: ${v[nk]};`
1125
- }
1126
- nested += `${fullSelector} { ${nestedProps} }\n`
1127
- } else {
1128
- props += `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}: ${v};`
1129
- }
1130
- }
1131
- if (props) css += `${resolvedSelector} { ${props} }\n`
1132
- if (nested) css += nested
1133
- }
1134
- }
1135
-
1136
- if (css) styleEl.textContent += css
1137
- }
1138
-
1139
- // --- Conditionals ---
1140
- // Reactive `.propName` (truthy) and `!propName` (falsy) blocks.
1141
- //
1142
- // Each conditional becomes its own `createEffect` so signal-driven predicates
1143
- // (state reads inside a function-valued prop) re-fire it. Block values are
1144
- // applied via inline styles (with CSS_PROPS_REGISTRY token resolution) and
1145
- // reverted by clearing the inline keys when the predicate flips off — the
1146
- // static class never bakes in the conditional values, so there is nothing to
1147
- // unmerge on revert.
1148
- //
1149
- // Imperative updates to a non-signal predicate (e.g. `element.update({ isActive })`
1150
- // where `isActive` is a plain element prop, not in state) re-run via
1151
- // `__conditionalRunners`, which `update.js` invokes after applying the prop.
1152
-
1153
- function registerConditionalEffects(element) {
1154
- const ref = element.__ref
1155
- ref.__conditionalRunners = []
1156
-
1157
- for (const key in element) {
1158
- if (!Object.prototype.hasOwnProperty.call(element, key)) continue
1159
- const c0 = key.charAt(0)
1160
- if (c0 !== '.' && c0 !== '!') continue
1161
-
1162
- const propName = key.slice(1)
1163
- if (!propName) continue
1164
-
1165
- const block = element[key]
1166
- if (!block || typeof block !== 'object') continue
1167
-
1168
- const negate = c0 === '!'
1169
- const local = { lastMatch: false, appliedKeys: null }
1170
-
1171
- const evaluate = () => {
1172
- if (!element.node) return
1173
-
1174
- // Resolution order matches css-in-props transformer
1175
- // (`applyConditionalCaseProps`): element prop → state → context.cases.
1176
- // Reading `element.state[propName]` inside the effect subscribes to the
1177
- // signal so state-only predicates re-fire on `state.update`.
1178
- let resolved
1179
- const propVal = element[propName]
1180
- if (propVal === undefined) {
1181
- if (element.state && propName in element.state) {
1182
- resolved = element.state[propName]
1183
- } else {
1184
- const caseFn = element.context?.cases?.[propName]
1185
- if (caseFn === undefined) return
1186
- resolved = typeof caseFn === 'function' ? caseFn.call(element, element) : caseFn
1187
- }
1188
- } else if (typeof propVal === 'function') {
1189
- resolved = propVal(element, element.state, element.context)
1190
- } else {
1191
- resolved = propVal
1192
- }
1193
-
1194
- const match = negate ? !resolved : !!resolved
1195
-
1196
- if (match === local.lastMatch) return
1197
- local.lastMatch = match
1198
-
1199
- const ds = element.context?.designSystem
1200
- if (ds) pushConfig(ds)
1201
- try {
1202
- if (match) {
1203
- const styles = {}
1204
- for (const prop in block) {
1205
- if (!Object.prototype.hasOwnProperty.call(block, prop)) continue
1206
- const raw = typeof block[prop] === 'function'
1207
- ? block[prop](element, element.state, element.context)
1208
- : block[prop]
1209
- if (raw == null) continue
1210
-
1211
- if (CSS_PROPS_REGISTRY[prop]) {
1212
- const r = CSS_PROPS_REGISTRY[prop](raw, element, element.state, element.context)
1213
- if (r) {
1214
- const arr = Array.isArray(r) ? r : [r]
1215
- for (const o of arr) if (o) Object.assign(styles, o)
1216
- }
1217
- } else if (DEFAULT_CSS_PROPERTIES_LIST && DEFAULT_CSS_PROPERTIES_LIST.has(prop)) {
1218
- styles[prop] = raw
1219
- }
1220
- }
1221
- const keys = Object.keys(styles)
1222
- if (keys.length) {
1223
- applyStyles(element.node, styles)
1224
- local.appliedKeys = keys
1225
- } else {
1226
- local.appliedKeys = null
1227
- }
1228
- } else if (local.appliedKeys) {
1229
- for (const k of local.appliedKeys) element.node.style[k] = ''
1230
- local.appliedKeys = null
1231
- }
1232
- } finally {
1233
- if (ds) popConfig()
1234
- }
1235
- }
1236
-
1237
- ref.__conditionalRunners.push(evaluate)
1238
-
1239
- const effect = createEffect(evaluate, { depth: ref.path.length })
1240
- ref.__effects.push(effect)
1241
- }
1242
- }
1243
-
1244
- // --- CSS-in-props ---
1245
- // Processes element properties through CSS_PROPS_REGISTRY transforms
1246
-
1247
- const __APPLY_CSS_STACK = new WeakSet()
1248
- function applyCssInProps(element) {
1249
- const { node } = element
1250
- if (!node) return
1251
-
1252
- if (__APPLY_CSS_STACK.has(element)) {
1253
- console.error('[applyCssInProps] re-entry detected for element', element.key, 'prop path:', element.__ref?.path)
1254
- return
1255
- }
1256
- __APPLY_CSS_STACK.add(element)
1257
-
1258
- const ds = element.context?.designSystem
1259
- if (ds) pushConfig(ds)
1260
-
1261
- try {
1262
- const cssStyles = {}
1263
- let hasStyles = false
1264
-
1265
- for (const prop in element) {
1266
- if (!Object.prototype.hasOwnProperty.call(element, prop)) continue
1267
- if (typeof element[prop] === 'function') continue
1268
- if (REGISTRY.has(prop)) continue
1269
-
1270
- const firstChar = prop.charCodeAt(0)
1271
- if (firstChar >= 65 && firstChar <= 90) continue
1272
- if (isEventHandler(prop)) continue
1273
-
1274
- if (CSS_PROPS_REGISTRY[prop]) {
1275
- const val = element[prop]
1276
- const cssResult = CSS_PROPS_REGISTRY[prop](val, element, element.state, element.context)
1277
- if (cssResult) {
1278
- if (Array.isArray(cssResult)) {
1279
- for (const r of cssResult) if (r) Object.assign(cssStyles, r)
1280
- } else {
1281
- Object.assign(cssStyles, cssResult)
1282
- }
1283
- hasStyles = true
1284
- }
1285
- } else if (DEFAULT_CSS_PROPERTIES_LIST && DEFAULT_CSS_PROPERTIES_LIST.has(prop)) {
1286
- const val = element[prop]
1287
- if (val != null && typeof val !== 'object') {
1288
- cssStyles[prop] = val
1289
- hasStyles = true
1290
- }
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: { ... }`.
1298
- const val = element[prop]
1299
- if (val != null && typeof val !== 'object') {
1300
- cssStyles[prop] = val
1301
- hasStyles = true
1302
- }
1303
- } else if (prop.charAt(0) === '@' || prop.charAt(0) === ':') {
1304
- // Selector-based CSS: @dark, @media, :hover
1305
- const val = element[prop]
1306
- if (val && typeof val === 'object') {
1307
- const resolved = {}
1308
- for (const k in val) {
1309
- let v = val[k]
1310
- // Evaluate function-valued CSS within selector blocks (e.g. @dark background)
1311
- if (typeof v === 'function') v = v(element, element.state, element.context)
1312
- if (v == null || typeof v === 'function') continue
1313
- if (CSS_PROPS_REGISTRY[k]) {
1314
- const result = CSS_PROPS_REGISTRY[k](v, element, element.state, element.context)
1315
- if (result) {
1316
- if (Array.isArray(result)) { for (const r of result) if (r) Object.assign(resolved, r) }
1317
- else Object.assign(resolved, result)
1318
- }
1319
- } else {
1320
- resolved[k] = v
1321
- }
1322
- }
1323
- if (Object.keys(resolved).length) {
1324
- // Per-element theme selectors (`@dark`, `@light`, or any custom
1325
- // scheme `@name`) all compile to a single
1326
- // `[data-theme="<scheme>"] &`
1327
- // rule. The !important marker ensures the themed value wins over
1328
- // the default (the unconditional property on the same element —
1329
- // e.g. `background: 'neutral100'`).
1330
- //
1331
- // `globalTheme: 'auto'` is handled outside the CSS: `scratch.set`
1332
- // watches `prefers-color-scheme` and writes `data-theme` on the
1333
- // scope root to follow OS changes live. That keeps a single CSS
1334
- // selector pattern — no `@media` duplicates to parse — and lets
1335
- // custom themes like `@ocean` use the same mechanism.
1336
- //
1337
- // We intentionally treat `@dark`/`@light` as theme selectors even
1338
- // if `designSystem.media.dark` / `.light` is defined — projects
1339
- // historically mapped them to `[data-theme="…"]` there, and
1340
- // routing through the media-shortcut branch would emit invalid
1341
- // `@media [data-theme="…"]` rules.
1342
- const isThemeSelector = prop.length > 1 && prop.charAt(0) === '@' &&
1343
- !prop.startsWith('@media') && !prop.startsWith('@keyframes') && !prop.startsWith('@supports') &&
1344
- (prop === '@dark' || prop === '@light' ||
1345
- !element.context?.designSystem?.media?.[prop.slice(1)])
1346
- if (isThemeSelector) {
1347
- const important = {}
1348
- for (const rk in resolved) {
1349
- const rv = resolved[rk]
1350
- important[rk] = typeof rv === 'string' && !rv.includes('!important') ? rv + ' !important' : rv
1351
- }
1352
- const scheme = prop.slice(1)
1353
- cssStyles[`[data-theme="${scheme}"] &`] = important
1354
- } else {
1355
- // Resolve design system media shortcuts (@mobileL -> @media (max-width: 768px))
1356
- let cssProp = prop
1357
- if (prop.charAt(0) === '@' && !prop.startsWith('@media') && !prop.startsWith('@keyframes')) {
1358
- const mediaKey = prop.slice(1)
1359
- const mediaVal = element.context?.designSystem?.media?.[mediaKey] ||
1360
- element.context?.designSystem?.media?.[mediaKey]
1361
- if (mediaVal) cssProp = `@media ${mediaVal}`
1362
- }
1363
- // Separate flat CSS props from nested selector objects
1364
- const flat = {}
1365
- for (const rk in resolved) {
1366
- const rv = resolved[rk]
1367
- if (rv && typeof rv === 'object' && !Array.isArray(rv)) {
1368
- // Nested selector (e.g. '> hgroup > h6': { color: 'blue' })
1369
- // Resolve CSS tokens in nested values
1370
- const nestedResolved = {}
1371
- for (const nk in rv) {
1372
- if (CSS_PROPS_REGISTRY[nk]) {
1373
- const nr = CSS_PROPS_REGISTRY[nk](rv[nk], element, element.state, element.context)
1374
- if (nr) {
1375
- if (Array.isArray(nr)) { for (const r of nr) if (r) Object.assign(nestedResolved, r) }
1376
- else Object.assign(nestedResolved, nr)
1377
- }
1378
- } else {
1379
- nestedResolved[nk] = rv[nk]
1380
- }
1381
- }
1382
- // Pseudo-selectors attach directly, combinators need space
1383
- const sep = rk.startsWith(':') ? '' : ' '
1384
- // Distribute nested selector across comma-separated parts
1385
- if (cssProp.includes(',')) {
1386
- const distributed = cssProp.split(',').map(s => `${s.trim()}${sep}${rk}`).join(', ')
1387
- cssStyles[distributed] = nestedResolved
1388
- } else {
1389
- cssStyles[`${cssProp}${sep}${rk}`] = nestedResolved
1390
- }
1391
- } else {
1392
- flat[rk] = rv
1393
- }
1394
- }
1395
- if (Object.keys(flat).length) cssStyles[cssProp] = flat
1396
- }
1397
- hasStyles = true
1398
- }
1399
- hasStyles = true
1400
- }
1401
- }
1402
- }
1403
-
1404
- if (hasStyles) {
1405
- // Document already set by the outer push at the top of applyCssInProps
1406
-
1407
- // Apply display:none !important as inline style (for show/hide to work reliably)
1408
- if (cssStyles.display && String(cssStyles.display).includes('!important')) {
1409
- node.style.setProperty('display', String(cssStyles.display).replace(/\s*!important\s*$/, ''), 'important')
1410
- delete cssStyles.display
1411
- }
1412
-
1413
- const className = atomicCss(cssStyles, null, element.context?.cssPrefix)
1414
- if (className && node.classList) {
1415
- className.split(' ').forEach(c => c && node.classList.add(c))
1416
- }
1417
-
1418
- // Enforce explicit display when it conflicts with generated display from flow/align
1419
- // Also handle hide:true / show:false (which set display:none via CSS_PROPS_REGISTRY)
1420
- const effectiveDisplay = element.display != null && typeof element.display !== 'function'
1421
- ? element.display
1422
- : (element.hide === true || element.show === false) ? 'none' : null
1423
- if (effectiveDisplay != null) {
1424
- const generatedDisplay = cssStyles.display
1425
- if (generatedDisplay && generatedDisplay !== effectiveDisplay) {
1426
- if (effectiveDisplay === 'none') {
1427
- // For display:none, remove conflicting atomic display classes instead of inline style
1428
- // (inline style would block conditional show/hide from updating later)
1429
- const toRemove = []
1430
- for (const cls of node.classList) {
1431
- if (cls.startsWith('_d-') && cls !== '_d-none') toRemove.push(cls)
1432
- }
1433
- for (const cls of toRemove) node.classList.remove(cls)
1434
- node.classList.add('_d-none')
1435
- } else {
1436
- // For grid/block/inline, inline style is safe
1437
- node.style.display = element.display
1438
- }
1439
- }
1440
- }
1441
- }
1442
- } finally {
1443
- if (ds) popConfig()
1444
- __APPLY_CSS_STACK.delete(element)
1445
- }
1446
- }
1447
-
1448
- // --- Attrs-in-props ---
1449
- // Applies HTML attributes from element properties based on tag
1450
-
1451
- function applyAttrsInProps(element) {
1452
- const { node } = element
1453
- if (!node || !node.setAttribute) return
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
-
1471
- // Apply attribute transforms (src, href, poster, etc. with file/template resolution)
1472
- const transformed = applyAttrTransforms(element)
1473
- for (const attr in transformed) {
1474
- const val = transformed[attr]
1475
- if (val != null && val !== false && typeof val !== 'function' && typeof val !== 'object') {
1476
- node.setAttribute(attr, val === true ? '' : val)
1477
- }
1478
- }
1479
-
1480
- const tag = element.tag || 'div'
1481
- for (const prop in element) {
1482
- if (!Object.prototype.hasOwnProperty.call(element, prop)) continue
1483
- if (typeof element[prop] === 'function') continue
1484
- if (REGISTRY.has(prop)) continue
1485
- if (prop === 'on') continue
1486
- if (transformed[prop] !== undefined) continue
1487
-
1488
- const firstChar = prop.charCodeAt(0)
1489
- if (firstChar >= 65 && firstChar <= 90) continue
1490
- if (isEventHandler(prop)) continue
1491
-
1492
- // Skip CSS properties — already handled by applyCssInProps via CSS.
1493
- // Setting design system tokens (e.g. 'A') as SVG/HTML attributes causes browser errors.
1494
- if (CSS_PROPS_REGISTRY[prop] || (DEFAULT_CSS_PROPERTIES_LIST && DEFAULT_CSS_PROPERTIES_LIST.has(prop))) continue
1495
-
1496
- // Check if this prop is a valid HTML attribute for this tag.
1497
- // Note: do NOT pre-guard with `HTML_ATTRIBUTES[prop]` — that map is keyed
1498
- // by tag name, not attribute name, so the guard would only ever pass for
1499
- // props whose name happens to also be a tag (e.g. `a`, `div`). The proper
1500
- // check is checkAttributeByTagName, which inspects the tag's attribute list.
1501
- if (checkAttributeByTagName(tag, prop)) {
1502
- let val = element[prop]
1503
- if (val == null || val === false) continue
1504
-
1505
- // Resolve {{ }} template literals
1506
- if (typeof val === 'string' && val.includes('{{')) {
1507
- val = replaceLiteralsWithObjectFields(val, element, element.state, element.context)
1508
- element[prop] = val
1509
- }
1510
-
1511
- if (val == null || val === false || typeof val === 'function' || typeof val === 'object') continue
1512
- node.setAttribute(prop, val === true ? '' : val)
1513
- }
1514
- }
1515
- }
1516
-
1517
- // --- Events ---
1518
- // v3 syntax: onXxx handlers directly on the component (no `on: {}` wrapper)
1519
-
1520
- function registerEvents(element, options) {
1521
- for (const key in element) {
1522
- if (!Object.prototype.hasOwnProperty.call(element, key)) continue
1523
- if (!isEventHandler(key)) continue
1524
-
1525
- let handler = element[key]
1526
-
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)) {
1531
- const fnName = handler
1532
- handler = function stringHandlerShortcut (ev) {
1533
- return element.call(fnName, ev)
1534
- }
1535
- }
1536
-
1537
- if (typeof handler !== 'function') continue
1538
-
1539
- const eventName = eventNameFrom(key)
1540
- if (LIFECYCLE_EVENTS.has(eventName)) continue
1541
-
1542
- registerEvent(toDomEventName(eventName), element, handler)
1543
- }
1544
- }
1545
-
1546
- // --- Children ---
1547
-
1548
- // Deep merge childProps with childDef — nested objects are merged, not replaced
1549
- function deepMergeChildProps(base, override) {
1550
- const result = { ...base }
1551
- for (const key in override) {
1552
- const bVal = result[key]
1553
- const oVal = override[key]
1554
- if (bVal && oVal && typeof bVal === 'object' && typeof oVal === 'object' &&
1555
- !Array.isArray(bVal) && !Array.isArray(oVal) &&
1556
- typeof bVal !== 'function' && typeof oVal !== 'function') {
1557
- result[key] = deepMergeChildProps(bVal, oVal)
1558
- } else {
1559
- result[key] = oVal
1560
- }
1561
- }
1562
- return result
1563
- }
1564
-
1565
- // Renders resolved children (array, object, or string state ref) into the element's DOM
1566
- function renderChildren(element, children, options) {
1567
- if (!children || !element.node) return
1568
-
1569
- const childExtends = element.childExtends
1570
-
1571
- if (Array.isArray(children)) {
1572
- const doc = element.context?.document || globalThis.document
1573
- const fragment = doc?.createDocumentFragment()
1574
- if (!fragment) return
1575
-
1576
- const childPropsRaw = element.childProps
1577
- const childrenAs = element.childrenAs
1578
-
1579
- for (let i = 0; i < children.length; i++) {
1580
- let childDef = children[i]
1581
- const rawChild = childDef
1582
-
1583
- if (typeof childDef === 'string' || typeof childDef === 'number') {
1584
- childDef = { text: childDef }
1585
- }
1586
-
1587
- // Resolve function-valued childProps per child
1588
- let childProps = childPropsRaw
1589
- if (typeof childPropsRaw === 'function') {
1590
- try {
1591
- childProps = childPropsRaw(element, childDef)
1592
- } catch (e) {
1593
- childProps = null
1594
- }
1595
- }
1596
-
1597
- if (childrenAs === 'state') {
1598
- // For string/number children, create state with both value and text for v3 compat
1599
- let stateVal = rawChild
1600
- if (typeof rawChild === 'string' || typeof rawChild === 'number') {
1601
- stateVal = { value: rawChild, text: rawChild }
1602
- }
1603
- childDef = childProps ? { ...childProps, state: stateVal } : { state: stateVal }
1604
- } else if (childProps) {
1605
- childDef = deepMergeChildProps(childProps, childDef)
1606
- }
1607
-
1608
- if (childExtends && !childDef.ignoreChildExtends) {
1609
- if (childDef.extends) {
1610
- const own = Array.isArray(childDef.extends) ? childDef.extends : [childDef.extends]
1611
- const parent = Array.isArray(childExtends) ? childExtends : [childExtends]
1612
- childDef = { ...childDef, extends: [...parent, ...own] }
1613
- } else {
1614
- childDef = { extends: childExtends, ...childDef }
1615
- }
1616
- }
1617
-
1618
- const childKey = childDef?.key || `${i}`
1619
- const child = create(childDef, element, childKey, options)
1620
- if (child?.node) fragment.appendChild(child.node)
1621
- }
1622
-
1623
- element.node.appendChild(fragment)
1624
- return
1625
- }
1626
-
1627
- if (typeof children === 'string') {
1628
- const stateVal = resolveStatePath(children, element)
1629
- if (Array.isArray(stateVal)) {
1630
- renderChildren(element, stateVal, options)
1631
- return
1632
- }
1633
- }
1634
-
1635
- if (typeof children === 'object') {
1636
- for (const childKey in children) {
1637
- if (REGISTRY.has(childKey)) continue
1638
-
1639
- let childDef = children[childKey]
1640
- if (childDef == null || childDef === false) continue
1641
-
1642
- if (typeof childDef === 'string' || typeof childDef === 'number') {
1643
- childDef = { text: childDef }
1644
- }
1645
-
1646
- if (element.childProps) {
1647
- childDef = deepMergeChildProps(element.childProps, childDef)
1648
- }
1649
-
1650
- if (childExtends && !childDef.ignoreChildExtends) {
1651
- if (childDef.extends) {
1652
- const own = Array.isArray(childDef.extends) ? childDef.extends : [childDef.extends]
1653
- const parent = Array.isArray(childExtends) ? childExtends : [childExtends]
1654
- childDef = { ...childDef, extends: [...parent, ...own] }
1655
- } else {
1656
- childDef = { extends: childExtends, ...childDef }
1657
- }
1658
- }
1659
-
1660
- create(childDef, element, childKey, options)
1661
- }
1662
- }
1663
- }
1664
-
1665
- function createChildren(element, options) {
1666
- let { children, content } = element
1667
- const childExtends = element.childExtends
1668
-
1669
- // Resolve function-valued children with reactive tracking
1670
- if (typeof children === 'function') {
1671
- const childrenFn = children
1672
- children = childrenFn(element, element.state, element.context)
1673
- element.children = children
1674
-
1675
- // Reactive effect: keyed reconciliation on update.
1676
- //
1677
- // Previously we wiped every child and rebuilt from scratch whenever
1678
- // the children factory produced a new result. That destroyed any
1679
- // DOM state inside the subtree — most visibly, an `<input>` being
1680
- // typed into would lose focus after every keystroke because its
1681
- // node was detached and replaced.
1682
- //
1683
- // Now we diff the incoming array against `ref.__children`:
1684
- // • keys still present → update the child's reactive state in
1685
- // place so its subscribers re-render,
1686
- // but the DOM node (and its focus /
1687
- // selection state) is preserved.
1688
- // • keys gone → remove + delete.
1689
- // • new keys → create and append.
1690
- //
1691
- // Non-array results and non-`childrenAs: 'state'` children still
1692
- // fall through to the full rebuild path since we can't safely
1693
- // update their internals in place.
1694
- const ref = element.__ref
1695
- let initialized = false
1696
- const effect = createEffect(() => {
1697
- const result = childrenFn(element, element.state, element.context)
1698
- if (!initialized) { initialized = true; return }
1699
- if (result === element.children) return
1700
-
1701
- element.children = result
1702
-
1703
- // Fast path: keyed reconciliation for array children + childrenAs=state.
1704
- // This is the only case where we can safely update in place, because
1705
- // each child owns a reactive store whose fields we can patch.
1706
- const isArr = Array.isArray(result)
1707
- const canReconcile = isArr && element.childrenAs === 'state'
1708
-
1709
- if (canReconcile) {
1710
- // Compute the new key order. Key resolution mirrors renderChildren:
1711
- // a `childProps` factory can lift `key` to the top level of the
1712
- // synthesized childDef; otherwise fall back to `rawChild.key`,
1713
- // finally falling back to the positional index.
1714
- const childPropsRaw = element.childProps
1715
- const newOrder = []
1716
- const itemByKey = new Map()
1717
- for (let i = 0; i < result.length; i++) {
1718
- const rawChild = result[i]
1719
- let childProps = childPropsRaw
1720
- if (typeof childPropsRaw === 'function') {
1721
- try { childProps = childPropsRaw(element, rawChild) } catch { childProps = null }
1722
- }
1723
- const key = (childProps && childProps.key) || (rawChild && rawChild.key) || `${i}`
1724
- newOrder.push(key)
1725
- itemByKey.set(key, { rawChild, childProps })
1726
- }
1727
-
1728
- const newKeySet = new Set(newOrder)
1729
- // Remove children whose keys are no longer present.
1730
- for (let i = ref.__children.length - 1; i >= 0; i--) {
1731
- const k = ref.__children[i]
1732
- if (!newKeySet.has(k)) {
1733
- const child = element[k]
1734
- if (child?.remove) child.remove()
1735
- delete element[k]
1736
- ref.__children.splice(i, 1)
1737
- }
1738
- }
1739
-
1740
- // Upsert children in order. Existing keys reuse their node and
1741
- // patch their state; new keys are created and appended.
1742
- const childExtends = element.childExtends
1743
- const doc = element.context?.document || globalThis.document
1744
- const fragment = doc?.createDocumentFragment()
1745
- for (const key of newOrder) {
1746
- const entry = itemByKey.get(key)
1747
- const rawChild = entry.rawChild
1748
- const existing = element[key]
1749
-
1750
- if (existing && existing.state) {
1751
- // Patch the existing store with the new item's fields via
1752
- // `state.replace(obj)` — unlike `update` (which only merges
1753
- // new keys) `replace` also REMOVES any prior keys that
1754
- // aren't on the new object. Critical for list reconcilers:
1755
- // without replace semantics, a row that went from parent
1756
- // summary → sub-row would inherit the old `_isParent: true`
1757
- // flag and render both roles simultaneously.
1758
- //
1759
- // Signals fire for added / changed / removed keys, so the
1760
- // child's `text` / `hide` / `style` gates re-evaluate and
1761
- // the DOM node (+ any focused input inside) stays untouched.
1762
- if (rawChild && typeof rawChild === 'object') {
1763
- const st = existing.state
1764
- if (typeof st.replace === 'function') {
1765
- st.replace(rawChild)
1766
- } else if (typeof st.update === 'function') {
1767
- // Fallback for stores without replace(): emulate it by
1768
- // nulling keys not in the new object, then update().
1769
- for (const k2 in st) {
1770
- if (k2 === '__element' || k2 === 'root' || k2 === 'parent' || k2 === 'update') continue
1771
- if (!Object.prototype.hasOwnProperty.call(rawChild, k2)) {
1772
- try { st[k2] = undefined } catch {}
1773
- }
1774
- }
1775
- st.update(rawChild)
1776
- } else {
1777
- for (const k2 in rawChild) st[k2] = rawChild[k2]
1778
- }
1779
- }
1780
- continue
1781
- }
1782
-
1783
- // New child — build the childDef the same way renderChildren does,
1784
- // then create + append.
1785
- let stateVal = rawChild
1786
- if (typeof rawChild === 'string' || typeof rawChild === 'number') {
1787
- stateVal = { value: rawChild, text: rawChild }
1788
- }
1789
- const childProps = entry.childProps
1790
- let childDef = childProps ? { ...childProps, state: stateVal } : { state: stateVal }
1791
-
1792
- if (childExtends && !childDef.ignoreChildExtends) {
1793
- if (childDef.extends) {
1794
- const own = Array.isArray(childDef.extends) ? childDef.extends : [childDef.extends]
1795
- const parent = Array.isArray(childExtends) ? childExtends : [childExtends]
1796
- childDef = { ...childDef, extends: [...parent, ...own] }
1797
- } else {
1798
- childDef = { extends: childExtends, ...childDef }
1799
- }
1800
- }
1801
-
1802
- const child = create(childDef, element, key, options)
1803
- if (child?.node && fragment) fragment.appendChild(child.node)
1804
- }
1805
- if (fragment && fragment.childNodes.length && element.node) {
1806
- element.node.appendChild(fragment)
1807
- }
1808
-
1809
- // Keep __children in the new order for stable future reconciliations.
1810
- ref.__children = newOrder
1811
- return
1812
- }
1813
-
1814
- // Fallback: full rebuild (non-array results, or childrenAs not set).
1815
- for (const key of [...ref.__children]) {
1816
- const child = element[key]
1817
- if (child?.remove) child.remove()
1818
- delete element[key]
1819
- }
1820
- ref.__children = []
1821
- renderChildren(element, result, options)
1822
- }, { depth: ref.path.length })
1823
- ref.__effects.push(effect)
1824
- }
1825
-
1826
- renderChildren(element, children || content, options)
1827
-
1828
- if (children || content) return
1829
-
1830
- // Iterate element properties for child components (Uppercase keys)
1831
- const components = element.context?.components || {}
1832
-
1833
- // v3 compat: resolve XxxExtends shorthand
1834
- // Pattern: StarIconExtends: 'Icon', name: 'star', fontSize: 'X2'
1835
- // Becomes: StarIcon: { extends: 'Icon', name: 'star', fontSize: 'X2' }
1836
- {
1837
- const keys = Object.keys(element)
1838
- for (let i = 0; i < keys.length; i++) {
1839
- const prop = keys[i]
1840
- if (!prop.endsWith('Extends') || typeof element[prop] !== 'string') continue
1841
- const childName = prop.slice(0, -7)
1842
- if (!childName || childName.charCodeAt(0) < 65 || childName.charCodeAt(0) > 90) continue
1843
-
1844
- const childDef = { extends: element[prop] }
1845
- delete element[prop]
1846
-
1847
- // Collect subsequent lowercase properties until next Uppercase key or end
1848
- for (let j = i + 1; j < keys.length; j++) {
1849
- const nextKey = keys[j]
1850
- const fc = nextKey.charCodeAt(0)
1851
- if (fc >= 65 && fc <= 90) break // Stop at next child/component key
1852
- if (REGISTRY.has(nextKey)) break // Stop at framework keys
1853
- if (typeof element[nextKey] === 'function' && (nextKey.startsWith('on') || nextKey.startsWith('.'))) break
1854
- const val = element[nextKey]
1855
- if (val !== undefined) {
1856
- childDef[nextKey] = val
1857
- delete element[nextKey]
1858
- }
1859
- }
1860
-
1861
- // Merge with existing child def if present
1862
- if (element[childName] && typeof element[childName] === 'object') {
1863
- element[childName] = { ...childDef, ...element[childName] }
1864
- } else {
1865
- element[childName] = childDef
1866
- }
1867
- }
1868
- }
1869
-
1870
- for (const prop in element) {
1871
- if (!Object.prototype.hasOwnProperty.call(element, prop)) continue
1872
- if (REGISTRY.has(prop)) continue
1873
- if (typeof element[prop] !== 'object' || element[prop] === null) continue
1874
-
1875
- const firstChar = prop.charCodeAt(0)
1876
- if (firstChar < 65 || firstChar > 90) continue
1877
-
1878
- let childDef = element[prop]
1879
- if (childDef.__ref) continue // Already created
1880
-
1881
- // Auto-extend from context component if key matches and no extends set
1882
- if (!childDef.extends && !childDef.extend) {
1883
- const comp = components[prop] || components[`smbls.${prop}`]
1884
- if (comp) {
1885
- childDef = { extends: comp, ...childDef }
1886
- }
1887
- }
1888
-
1889
- // Apply childProps (e.g. fontSize, padding from parent's childProps)
1890
- if (element.childProps) {
1891
- childDef = { ...element.childProps, ...childDef }
1892
- }
1893
-
1894
- if (childExtends && !childDef.ignoreChildExtends) {
1895
- if (childDef.extends) {
1896
- const own = Array.isArray(childDef.extends) ? childDef.extends : [childDef.extends]
1897
- const parent = Array.isArray(childExtends) ? childExtends : [childExtends]
1898
- childDef = { ...childDef, extends: [...parent, ...own] }
1899
- } else {
1900
- childDef = { extends: childExtends, ...childDef }
1901
- }
1902
- }
1903
-
1904
- create(childDef, element, prop, options)
1905
- }
1906
- }
1907
-
1908
- // --- DOM Assignment ---
1909
-
1910
- function assignNode(element, parent, attachOptions) {
1911
- const { node } = element
1912
- if (!node) return
1913
-
1914
- // Expose DOMQL ref on DOM node in dev mode (use $0.ref in DevTools)
1915
- if (process.env.NODE_ENV !== 'production') {
1916
- node.ref = element
1917
- }
1918
-
1919
- // Support both DOMQL elements (.node) and raw DOM nodes (root case)
1920
- const parentNode = parent?.node || (parent?.nodeType ? parent : null)
1921
- if (!parentNode) return
1922
-
1923
- if (attachOptions?.position === 'before' && attachOptions.node) {
1924
- parentNode.insertBefore(node, attachOptions.node)
1925
- } else if (attachOptions?.position === 'after' && attachOptions.node) {
1926
- const nextSib = attachOptions.node.nextSibling
1927
- if (nextSib) {
1928
- parentNode.insertBefore(node, nextSib)
1929
- } else {
1930
- parentNode.appendChild(node)
1931
- }
1932
- } else {
1933
- parentNode.appendChild(node)
1934
- }
1935
- }
1936
-
1937
- function triggerLifecycle(eventSuffix, element, options) {
1938
- const hookName = eventSuffix.charAt(0).toLowerCase() + eventSuffix.slice(1)
1939
- if (element.context?.plugins?.length) runPluginHook(hookName, element, options)
1940
-
1941
- const key = 'on' + eventSuffix
1942
- const handler = element[key]
1943
- if (typeof handler === 'function') {
1944
- // Scope config to this element's context so lifecycle handlers that
1945
- // inject CSS or resolve tokens use the right app. The document is
1946
- // stored on the config, so getActiveDocument() resolves it too.
1947
- const ds = element.context?.designSystem
1948
- if (ds) pushConfig(ds)
1949
- try {
1950
- const result = handler(element, element.state, element.context, options)
1951
- if (result === false) return false
1952
- } catch (err) {
1953
- // Route the error through the plugin error hook so observers
1954
- // (e.g. the analyze plugin) see it with full element context.
1955
- // Falls back to console.error when no plugin captures it.
1956
- const handled = element.context?.plugins?.length &&
1957
- runPluginHook('error', element, { hook: key, error: err })
1958
- if (!handled) console.error(`[DOMQL] Lifecycle "${key}" error:`, err)
1959
- if (element.context?.strictMode) throw err
1960
- } finally {
1961
- if (ds) popConfig()
1962
- }
1963
- }
1964
- return true
1965
- }
1966
-
1967
- export default create