@symbo.ls/element 3.14.3 → 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,1987 +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
- // `&` in the nested key references the outer's resolved selector
1120
- // (standard CSS-nesting semantics). `&.X` chains on the outer,
1121
- // `& .X` is a descendant of the outer, `&:hover` / `&[disabled]`
1122
- // chain a pseudo/attr selector — all of these work because the
1123
- // replace preserves the user's whitespace and punctuation between
1124
- // `&` and the rest. When the nested key has no `&`, treat it as
1125
- // an implicit descendant (preserves keys like `'> h1'` rendering
1126
- // as `outer > h1`).
1127
- const fullSelector = k.includes('&')
1128
- ? resolvedSelector.split(', ').map(s => k.replace(/&/g, s)).join(', ')
1129
- : resolvedSelector.split(', ').map(s => `${s} ${k}`).join(', ')
1130
- let nestedProps = ''
1131
- for (const nk in v) {
1132
- nestedProps += `${nk.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}: ${v[nk]};`
1133
- }
1134
- nested += `${fullSelector} { ${nestedProps} }\n`
1135
- } else {
1136
- props += `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}: ${v};`
1137
- }
1138
- }
1139
- if (props) css += `${resolvedSelector} { ${props} }\n`
1140
- if (nested) css += nested
1141
- }
1142
- }
1143
-
1144
- if (css) styleEl.textContent += css
1145
- }
1146
-
1147
- // --- Conditionals ---
1148
- // Reactive `.propName` (truthy) and `!propName` (falsy) blocks.
1149
- //
1150
- // Each conditional becomes its own `createEffect` so signal-driven predicates
1151
- // (state reads inside a function-valued prop) re-fire it. Block values are
1152
- // applied via inline styles (with CSS_PROPS_REGISTRY token resolution) and
1153
- // reverted by clearing the inline keys when the predicate flips off — the
1154
- // static class never bakes in the conditional values, so there is nothing to
1155
- // unmerge on revert.
1156
- //
1157
- // Imperative updates to a non-signal predicate (e.g. `element.update({ isActive })`
1158
- // where `isActive` is a plain element prop, not in state) re-run via
1159
- // `__conditionalRunners`, which `update.js` invokes after applying the prop.
1160
-
1161
- function registerConditionalEffects(element) {
1162
- const ref = element.__ref
1163
- ref.__conditionalRunners = []
1164
-
1165
- for (const key in element) {
1166
- if (!Object.prototype.hasOwnProperty.call(element, key)) continue
1167
- const c0 = key.charAt(0)
1168
- if (c0 !== '.' && c0 !== '!') continue
1169
-
1170
- const propName = key.slice(1)
1171
- if (!propName) continue
1172
-
1173
- const block = element[key]
1174
- if (!block || typeof block !== 'object') continue
1175
-
1176
- const negate = c0 === '!'
1177
- const local = { lastMatch: false, appliedKeys: null }
1178
-
1179
- const evaluate = () => {
1180
- if (!element.node) return
1181
-
1182
- // Resolution order matches css-in-props transformer
1183
- // (`applyConditionalCaseProps`): element prop → state → context.cases.
1184
- // Reading `element.state[propName]` inside the effect subscribes to the
1185
- // signal so state-only predicates re-fire on `state.update`.
1186
- let resolved
1187
- const propVal = element[propName]
1188
- if (propVal === undefined) {
1189
- if (element.state && propName in element.state) {
1190
- resolved = element.state[propName]
1191
- } else {
1192
- const caseFn = element.context?.cases?.[propName]
1193
- if (caseFn === undefined) return
1194
- resolved = typeof caseFn === 'function' ? caseFn.call(element, element) : caseFn
1195
- }
1196
- } else if (typeof propVal === 'function') {
1197
- resolved = propVal(element, element.state, element.context)
1198
- } else {
1199
- resolved = propVal
1200
- }
1201
-
1202
- const match = negate ? !resolved : !!resolved
1203
-
1204
- if (match === local.lastMatch) return
1205
- local.lastMatch = match
1206
-
1207
- const ds = element.context?.designSystem
1208
- if (ds) pushConfig(ds)
1209
- try {
1210
- if (match) {
1211
- const styles = {}
1212
- for (const prop in block) {
1213
- if (!Object.prototype.hasOwnProperty.call(block, prop)) continue
1214
- const raw = typeof block[prop] === 'function'
1215
- ? block[prop](element, element.state, element.context)
1216
- : block[prop]
1217
- if (raw == null) continue
1218
-
1219
- if (CSS_PROPS_REGISTRY[prop]) {
1220
- const r = CSS_PROPS_REGISTRY[prop](raw, element, element.state, element.context)
1221
- if (r) {
1222
- const arr = Array.isArray(r) ? r : [r]
1223
- for (const o of arr) if (o) Object.assign(styles, o)
1224
- }
1225
- } else if (DEFAULT_CSS_PROPERTIES_LIST && DEFAULT_CSS_PROPERTIES_LIST.has(prop)) {
1226
- styles[prop] = raw
1227
- }
1228
- }
1229
- const keys = Object.keys(styles)
1230
- if (keys.length) {
1231
- applyStyles(element.node, styles)
1232
- local.appliedKeys = keys
1233
- } else {
1234
- local.appliedKeys = null
1235
- }
1236
- } else if (local.appliedKeys) {
1237
- for (const k of local.appliedKeys) element.node.style[k] = ''
1238
- local.appliedKeys = null
1239
- }
1240
- } finally {
1241
- if (ds) popConfig()
1242
- }
1243
- }
1244
-
1245
- ref.__conditionalRunners.push(evaluate)
1246
-
1247
- const effect = createEffect(evaluate, { depth: ref.path.length })
1248
- ref.__effects.push(effect)
1249
- }
1250
- }
1251
-
1252
- // --- CSS-in-props ---
1253
- // Processes element properties through CSS_PROPS_REGISTRY transforms
1254
-
1255
- const __APPLY_CSS_STACK = new WeakSet()
1256
- function applyCssInProps(element) {
1257
- const { node } = element
1258
- if (!node) return
1259
-
1260
- if (__APPLY_CSS_STACK.has(element)) {
1261
- console.error('[applyCssInProps] re-entry detected for element', element.key, 'prop path:', element.__ref?.path)
1262
- return
1263
- }
1264
- __APPLY_CSS_STACK.add(element)
1265
-
1266
- const ds = element.context?.designSystem
1267
- if (ds) pushConfig(ds)
1268
-
1269
- try {
1270
- const cssStyles = {}
1271
- let hasStyles = false
1272
-
1273
- for (const prop in element) {
1274
- if (!Object.prototype.hasOwnProperty.call(element, prop)) continue
1275
- if (typeof element[prop] === 'function') continue
1276
- if (REGISTRY.has(prop)) continue
1277
-
1278
- const firstChar = prop.charCodeAt(0)
1279
- if (firstChar >= 65 && firstChar <= 90) continue
1280
- if (isEventHandler(prop)) continue
1281
-
1282
- if (CSS_PROPS_REGISTRY[prop]) {
1283
- const val = element[prop]
1284
- const cssResult = CSS_PROPS_REGISTRY[prop](val, element, element.state, element.context)
1285
- if (cssResult) {
1286
- if (Array.isArray(cssResult)) {
1287
- for (const r of cssResult) if (r) Object.assign(cssStyles, r)
1288
- } else {
1289
- Object.assign(cssStyles, cssResult)
1290
- }
1291
- hasStyles = true
1292
- }
1293
- } else if (DEFAULT_CSS_PROPERTIES_LIST && DEFAULT_CSS_PROPERTIES_LIST.has(prop)) {
1294
- const val = element[prop]
1295
- if (val != null && typeof val !== 'object') {
1296
- cssStyles[prop] = val
1297
- hasStyles = true
1298
- }
1299
- } else if (prop.charCodeAt(0) === 45) {
1300
- // FT-FRAMEWORK-4: any dash-prefixed CSS key — both custom properties
1301
- // (`--var-name`) and vendor-prefixed properties
1302
- // (`-webkit-text-stroke`, `-moz-osx-font-smoothing`, …) — pass
1303
- // straight through the atomic CSS engine. Previously only `--`
1304
- // matched; `-webkit-text-stroke: 1px darkgrey` at the top level was
1305
- // silently dropped, forcing consumers to wrap it in `style: { ... }`.
1306
- const val = element[prop]
1307
- if (val != null && typeof val !== 'object') {
1308
- cssStyles[prop] = val
1309
- hasStyles = true
1310
- }
1311
- } else if (prop.charAt(0) === '&') {
1312
- // Top-level scope-anchored selector — e.g. `'& .token': { '&.keyword': {color} }`
1313
- // — flows straight to the atomic CSS engine, which substitutes `&` with
1314
- // the generated class. The `:` and `@` branches below already do this for
1315
- // pseudo-classes and at-rules; without this branch, top-level `&` keys
1316
- // were silently dropped and projects had to wrap every scope rule in a
1317
- // `style: {}` block (or inject a `<style>` child as a workaround).
1318
- const val = element[prop]
1319
- if (val && typeof val === 'object') {
1320
- cssStyles[prop] = val
1321
- hasStyles = true
1322
- }
1323
- } else if (prop.charAt(0) === '@' || prop.charAt(0) === ':') {
1324
- // Selector-based CSS: @dark, @media, :hover
1325
- const val = element[prop]
1326
- if (val && typeof val === 'object') {
1327
- const resolved = {}
1328
- for (const k in val) {
1329
- let v = val[k]
1330
- // Evaluate function-valued CSS within selector blocks (e.g. @dark background)
1331
- if (typeof v === 'function') v = v(element, element.state, element.context)
1332
- if (v == null || typeof v === 'function') continue
1333
- if (CSS_PROPS_REGISTRY[k]) {
1334
- const result = CSS_PROPS_REGISTRY[k](v, element, element.state, element.context)
1335
- if (result) {
1336
- if (Array.isArray(result)) { for (const r of result) if (r) Object.assign(resolved, r) }
1337
- else Object.assign(resolved, result)
1338
- }
1339
- } else {
1340
- resolved[k] = v
1341
- }
1342
- }
1343
- if (Object.keys(resolved).length) {
1344
- // Per-element theme selectors (`@dark`, `@light`, or any custom
1345
- // scheme `@name`) all compile to a single
1346
- // `[data-theme="<scheme>"] &`
1347
- // rule. The !important marker ensures the themed value wins over
1348
- // the default (the unconditional property on the same element —
1349
- // e.g. `background: 'neutral100'`).
1350
- //
1351
- // `globalTheme: 'auto'` is handled outside the CSS: `scratch.set`
1352
- // watches `prefers-color-scheme` and writes `data-theme` on the
1353
- // scope root to follow OS changes live. That keeps a single CSS
1354
- // selector pattern — no `@media` duplicates to parse — and lets
1355
- // custom themes like `@ocean` use the same mechanism.
1356
- //
1357
- // We intentionally treat `@dark`/`@light` as theme selectors even
1358
- // if `designSystem.media.dark` / `.light` is defined — projects
1359
- // historically mapped them to `[data-theme="…"]` there, and
1360
- // routing through the media-shortcut branch would emit invalid
1361
- // `@media [data-theme="…"]` rules.
1362
- const isThemeSelector = prop.length > 1 && prop.charAt(0) === '@' &&
1363
- !prop.startsWith('@media') && !prop.startsWith('@keyframes') && !prop.startsWith('@supports') &&
1364
- (prop === '@dark' || prop === '@light' ||
1365
- !element.context?.designSystem?.media?.[prop.slice(1)])
1366
- if (isThemeSelector) {
1367
- const important = {}
1368
- for (const rk in resolved) {
1369
- const rv = resolved[rk]
1370
- important[rk] = typeof rv === 'string' && !rv.includes('!important') ? rv + ' !important' : rv
1371
- }
1372
- const scheme = prop.slice(1)
1373
- cssStyles[`[data-theme="${scheme}"] &`] = important
1374
- } else {
1375
- // Resolve design system media shortcuts (@mobileL -> @media (max-width: 768px))
1376
- let cssProp = prop
1377
- if (prop.charAt(0) === '@' && !prop.startsWith('@media') && !prop.startsWith('@keyframes')) {
1378
- const mediaKey = prop.slice(1)
1379
- const mediaVal = element.context?.designSystem?.media?.[mediaKey] ||
1380
- element.context?.designSystem?.media?.[mediaKey]
1381
- if (mediaVal) cssProp = `@media ${mediaVal}`
1382
- }
1383
- // Separate flat CSS props from nested selector objects
1384
- const flat = {}
1385
- for (const rk in resolved) {
1386
- const rv = resolved[rk]
1387
- if (rv && typeof rv === 'object' && !Array.isArray(rv)) {
1388
- // Nested selector (e.g. '> hgroup > h6': { color: 'blue' })
1389
- // Resolve CSS tokens in nested values
1390
- const nestedResolved = {}
1391
- for (const nk in rv) {
1392
- if (CSS_PROPS_REGISTRY[nk]) {
1393
- const nr = CSS_PROPS_REGISTRY[nk](rv[nk], element, element.state, element.context)
1394
- if (nr) {
1395
- if (Array.isArray(nr)) { for (const r of nr) if (r) Object.assign(nestedResolved, r) }
1396
- else Object.assign(nestedResolved, nr)
1397
- }
1398
- } else {
1399
- nestedResolved[nk] = rv[nk]
1400
- }
1401
- }
1402
- // Pseudo-selectors attach directly, combinators need space
1403
- const sep = rk.startsWith(':') ? '' : ' '
1404
- // Distribute nested selector across comma-separated parts
1405
- if (cssProp.includes(',')) {
1406
- const distributed = cssProp.split(',').map(s => `${s.trim()}${sep}${rk}`).join(', ')
1407
- cssStyles[distributed] = nestedResolved
1408
- } else {
1409
- cssStyles[`${cssProp}${sep}${rk}`] = nestedResolved
1410
- }
1411
- } else {
1412
- flat[rk] = rv
1413
- }
1414
- }
1415
- if (Object.keys(flat).length) cssStyles[cssProp] = flat
1416
- }
1417
- hasStyles = true
1418
- }
1419
- hasStyles = true
1420
- }
1421
- }
1422
- }
1423
-
1424
- if (hasStyles) {
1425
- // Document already set by the outer push at the top of applyCssInProps
1426
-
1427
- // Apply display:none !important as inline style (for show/hide to work reliably)
1428
- if (cssStyles.display && String(cssStyles.display).includes('!important')) {
1429
- node.style.setProperty('display', String(cssStyles.display).replace(/\s*!important\s*$/, ''), 'important')
1430
- delete cssStyles.display
1431
- }
1432
-
1433
- const className = atomicCss(cssStyles, null, element.context?.cssPrefix)
1434
- if (className && node.classList) {
1435
- className.split(' ').forEach(c => c && node.classList.add(c))
1436
- }
1437
-
1438
- // Enforce explicit display when it conflicts with generated display from flow/align
1439
- // Also handle hide:true / show:false (which set display:none via CSS_PROPS_REGISTRY)
1440
- const effectiveDisplay = element.display != null && typeof element.display !== 'function'
1441
- ? element.display
1442
- : (element.hide === true || element.show === false) ? 'none' : null
1443
- if (effectiveDisplay != null) {
1444
- const generatedDisplay = cssStyles.display
1445
- if (generatedDisplay && generatedDisplay !== effectiveDisplay) {
1446
- if (effectiveDisplay === 'none') {
1447
- // For display:none, remove conflicting atomic display classes instead of inline style
1448
- // (inline style would block conditional show/hide from updating later)
1449
- const toRemove = []
1450
- for (const cls of node.classList) {
1451
- if (cls.startsWith('_d-') && cls !== '_d-none') toRemove.push(cls)
1452
- }
1453
- for (const cls of toRemove) node.classList.remove(cls)
1454
- node.classList.add('_d-none')
1455
- } else {
1456
- // For grid/block/inline, inline style is safe
1457
- node.style.display = element.display
1458
- }
1459
- }
1460
- }
1461
- }
1462
- } finally {
1463
- if (ds) popConfig()
1464
- __APPLY_CSS_STACK.delete(element)
1465
- }
1466
- }
1467
-
1468
- // --- Attrs-in-props ---
1469
- // Applies HTML attributes from element properties based on tag
1470
-
1471
- function applyAttrsInProps(element) {
1472
- const { node } = element
1473
- if (!node || !node.setAttribute) return
1474
-
1475
- // Apply STATIC nested aria.X / data.X values at mount. Function values
1476
- // are owned by the reactive aria/data effects in registerEffects — those
1477
- // re-run on state changes. Without this loop, aria/data static values
1478
- // declared as nested objects (`aria: { label: 'Search' }`) silently
1479
- // dropped — `applyStaticMixins` already promoted top-level data.X to
1480
- // dataset and the parallel aria branch had no equivalent.
1481
- if (element.aria && typeof element.aria === 'object') {
1482
- for (const ariaKey in element.aria) {
1483
- const v = element.aria[ariaKey]
1484
- if (typeof v === 'function' || typeof v === 'object') continue
1485
- if (v == null || v === false) continue
1486
- const attrName = 'aria-' + ariaKey.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
1487
- node.setAttribute(attrName, v === true ? '' : v)
1488
- }
1489
- }
1490
-
1491
- // Apply attribute transforms (src, href, poster, etc. with file/template resolution)
1492
- const transformed = applyAttrTransforms(element)
1493
- for (const attr in transformed) {
1494
- const val = transformed[attr]
1495
- if (val != null && val !== false && typeof val !== 'function' && typeof val !== 'object') {
1496
- node.setAttribute(attr, val === true ? '' : val)
1497
- }
1498
- }
1499
-
1500
- const tag = element.tag || 'div'
1501
- for (const prop in element) {
1502
- if (!Object.prototype.hasOwnProperty.call(element, prop)) continue
1503
- if (typeof element[prop] === 'function') continue
1504
- if (REGISTRY.has(prop)) continue
1505
- if (prop === 'on') continue
1506
- if (transformed[prop] !== undefined) continue
1507
-
1508
- const firstChar = prop.charCodeAt(0)
1509
- if (firstChar >= 65 && firstChar <= 90) continue
1510
- if (isEventHandler(prop)) continue
1511
-
1512
- // Skip CSS properties — already handled by applyCssInProps via CSS.
1513
- // Setting design system tokens (e.g. 'A') as SVG/HTML attributes causes browser errors.
1514
- if (CSS_PROPS_REGISTRY[prop] || (DEFAULT_CSS_PROPERTIES_LIST && DEFAULT_CSS_PROPERTIES_LIST.has(prop))) continue
1515
-
1516
- // Check if this prop is a valid HTML attribute for this tag.
1517
- // Note: do NOT pre-guard with `HTML_ATTRIBUTES[prop]` — that map is keyed
1518
- // by tag name, not attribute name, so the guard would only ever pass for
1519
- // props whose name happens to also be a tag (e.g. `a`, `div`). The proper
1520
- // check is checkAttributeByTagName, which inspects the tag's attribute list.
1521
- if (checkAttributeByTagName(tag, prop)) {
1522
- let val = element[prop]
1523
- if (val == null || val === false) continue
1524
-
1525
- // Resolve {{ }} template literals
1526
- if (typeof val === 'string' && val.includes('{{')) {
1527
- val = replaceLiteralsWithObjectFields(val, element, element.state, element.context)
1528
- element[prop] = val
1529
- }
1530
-
1531
- if (val == null || val === false || typeof val === 'function' || typeof val === 'object') continue
1532
- node.setAttribute(prop, val === true ? '' : val)
1533
- }
1534
- }
1535
- }
1536
-
1537
- // --- Events ---
1538
- // v3 syntax: onXxx handlers directly on the component (no `on: {}` wrapper)
1539
-
1540
- function registerEvents(element, options) {
1541
- for (const key in element) {
1542
- if (!Object.prototype.hasOwnProperty.call(element, key)) continue
1543
- if (!isEventHandler(key)) continue
1544
-
1545
- let handler = element[key]
1546
-
1547
- // String shortcut: onClick: 'fnName' → el.call('fnName', ev).
1548
- // Only valid JS identifiers — arbitrary strings like 'not a function' fall
1549
- // through to the typeof-function check below and are skipped.
1550
- if (typeof handler === 'string' && /^[A-Za-z_$][\w$]*$/.test(handler)) {
1551
- const fnName = handler
1552
- handler = function stringHandlerShortcut (ev) {
1553
- return element.call(fnName, ev)
1554
- }
1555
- }
1556
-
1557
- if (typeof handler !== 'function') continue
1558
-
1559
- const eventName = eventNameFrom(key)
1560
- if (LIFECYCLE_EVENTS.has(eventName)) continue
1561
-
1562
- registerEvent(toDomEventName(eventName), element, handler)
1563
- }
1564
- }
1565
-
1566
- // --- Children ---
1567
-
1568
- // Deep merge childProps with childDef — nested objects are merged, not replaced
1569
- function deepMergeChildProps(base, override) {
1570
- const result = { ...base }
1571
- for (const key in override) {
1572
- const bVal = result[key]
1573
- const oVal = override[key]
1574
- if (bVal && oVal && typeof bVal === 'object' && typeof oVal === 'object' &&
1575
- !Array.isArray(bVal) && !Array.isArray(oVal) &&
1576
- typeof bVal !== 'function' && typeof oVal !== 'function') {
1577
- result[key] = deepMergeChildProps(bVal, oVal)
1578
- } else {
1579
- result[key] = oVal
1580
- }
1581
- }
1582
- return result
1583
- }
1584
-
1585
- // Renders resolved children (array, object, or string state ref) into the element's DOM
1586
- function renderChildren(element, children, options) {
1587
- if (!children || !element.node) return
1588
-
1589
- const childExtends = element.childExtends
1590
-
1591
- if (Array.isArray(children)) {
1592
- const doc = element.context?.document || globalThis.document
1593
- const fragment = doc?.createDocumentFragment()
1594
- if (!fragment) return
1595
-
1596
- const childPropsRaw = element.childProps
1597
- const childrenAs = element.childrenAs
1598
-
1599
- for (let i = 0; i < children.length; i++) {
1600
- let childDef = children[i]
1601
- const rawChild = childDef
1602
-
1603
- if (typeof childDef === 'string' || typeof childDef === 'number') {
1604
- childDef = { text: childDef }
1605
- }
1606
-
1607
- // Resolve function-valued childProps per child
1608
- let childProps = childPropsRaw
1609
- if (typeof childPropsRaw === 'function') {
1610
- try {
1611
- childProps = childPropsRaw(element, childDef)
1612
- } catch (e) {
1613
- childProps = null
1614
- }
1615
- }
1616
-
1617
- if (childrenAs === 'state') {
1618
- // For string/number children, create state with both value and text for v3 compat
1619
- let stateVal = rawChild
1620
- if (typeof rawChild === 'string' || typeof rawChild === 'number') {
1621
- stateVal = { value: rawChild, text: rawChild }
1622
- }
1623
- childDef = childProps ? { ...childProps, state: stateVal } : { state: stateVal }
1624
- } else if (childProps) {
1625
- childDef = deepMergeChildProps(childProps, childDef)
1626
- }
1627
-
1628
- if (childExtends && !childDef.ignoreChildExtends) {
1629
- if (childDef.extends) {
1630
- const own = Array.isArray(childDef.extends) ? childDef.extends : [childDef.extends]
1631
- const parent = Array.isArray(childExtends) ? childExtends : [childExtends]
1632
- childDef = { ...childDef, extends: [...parent, ...own] }
1633
- } else {
1634
- childDef = { extends: childExtends, ...childDef }
1635
- }
1636
- }
1637
-
1638
- const childKey = childDef?.key || `${i}`
1639
- const child = create(childDef, element, childKey, options)
1640
- if (child?.node) fragment.appendChild(child.node)
1641
- }
1642
-
1643
- element.node.appendChild(fragment)
1644
- return
1645
- }
1646
-
1647
- if (typeof children === 'string') {
1648
- const stateVal = resolveStatePath(children, element)
1649
- if (Array.isArray(stateVal)) {
1650
- renderChildren(element, stateVal, options)
1651
- return
1652
- }
1653
- }
1654
-
1655
- if (typeof children === 'object') {
1656
- for (const childKey in children) {
1657
- if (REGISTRY.has(childKey)) continue
1658
-
1659
- let childDef = children[childKey]
1660
- if (childDef == null || childDef === false) continue
1661
-
1662
- if (typeof childDef === 'string' || typeof childDef === 'number') {
1663
- childDef = { text: childDef }
1664
- }
1665
-
1666
- if (element.childProps) {
1667
- childDef = deepMergeChildProps(element.childProps, childDef)
1668
- }
1669
-
1670
- if (childExtends && !childDef.ignoreChildExtends) {
1671
- if (childDef.extends) {
1672
- const own = Array.isArray(childDef.extends) ? childDef.extends : [childDef.extends]
1673
- const parent = Array.isArray(childExtends) ? childExtends : [childExtends]
1674
- childDef = { ...childDef, extends: [...parent, ...own] }
1675
- } else {
1676
- childDef = { extends: childExtends, ...childDef }
1677
- }
1678
- }
1679
-
1680
- create(childDef, element, childKey, options)
1681
- }
1682
- }
1683
- }
1684
-
1685
- function createChildren(element, options) {
1686
- let { children, content } = element
1687
- const childExtends = element.childExtends
1688
-
1689
- // Resolve function-valued children with reactive tracking
1690
- if (typeof children === 'function') {
1691
- const childrenFn = children
1692
- children = childrenFn(element, element.state, element.context)
1693
- element.children = children
1694
-
1695
- // Reactive effect: keyed reconciliation on update.
1696
- //
1697
- // Previously we wiped every child and rebuilt from scratch whenever
1698
- // the children factory produced a new result. That destroyed any
1699
- // DOM state inside the subtree — most visibly, an `<input>` being
1700
- // typed into would lose focus after every keystroke because its
1701
- // node was detached and replaced.
1702
- //
1703
- // Now we diff the incoming array against `ref.__children`:
1704
- // • keys still present → update the child's reactive state in
1705
- // place so its subscribers re-render,
1706
- // but the DOM node (and its focus /
1707
- // selection state) is preserved.
1708
- // • keys gone → remove + delete.
1709
- // • new keys → create and append.
1710
- //
1711
- // Non-array results and non-`childrenAs: 'state'` children still
1712
- // fall through to the full rebuild path since we can't safely
1713
- // update their internals in place.
1714
- const ref = element.__ref
1715
- let initialized = false
1716
- const effect = createEffect(() => {
1717
- const result = childrenFn(element, element.state, element.context)
1718
- if (!initialized) { initialized = true; return }
1719
- if (result === element.children) return
1720
-
1721
- element.children = result
1722
-
1723
- // Fast path: keyed reconciliation for array children + childrenAs=state.
1724
- // This is the only case where we can safely update in place, because
1725
- // each child owns a reactive store whose fields we can patch.
1726
- const isArr = Array.isArray(result)
1727
- const canReconcile = isArr && element.childrenAs === 'state'
1728
-
1729
- if (canReconcile) {
1730
- // Compute the new key order. Key resolution mirrors renderChildren:
1731
- // a `childProps` factory can lift `key` to the top level of the
1732
- // synthesized childDef; otherwise fall back to `rawChild.key`,
1733
- // finally falling back to the positional index.
1734
- const childPropsRaw = element.childProps
1735
- const newOrder = []
1736
- const itemByKey = new Map()
1737
- for (let i = 0; i < result.length; i++) {
1738
- const rawChild = result[i]
1739
- let childProps = childPropsRaw
1740
- if (typeof childPropsRaw === 'function') {
1741
- try { childProps = childPropsRaw(element, rawChild) } catch { childProps = null }
1742
- }
1743
- const key = (childProps && childProps.key) || (rawChild && rawChild.key) || `${i}`
1744
- newOrder.push(key)
1745
- itemByKey.set(key, { rawChild, childProps })
1746
- }
1747
-
1748
- const newKeySet = new Set(newOrder)
1749
- // Remove children whose keys are no longer present.
1750
- for (let i = ref.__children.length - 1; i >= 0; i--) {
1751
- const k = ref.__children[i]
1752
- if (!newKeySet.has(k)) {
1753
- const child = element[k]
1754
- if (child?.remove) child.remove()
1755
- delete element[k]
1756
- ref.__children.splice(i, 1)
1757
- }
1758
- }
1759
-
1760
- // Upsert children in order. Existing keys reuse their node and
1761
- // patch their state; new keys are created and appended.
1762
- const childExtends = element.childExtends
1763
- const doc = element.context?.document || globalThis.document
1764
- const fragment = doc?.createDocumentFragment()
1765
- for (const key of newOrder) {
1766
- const entry = itemByKey.get(key)
1767
- const rawChild = entry.rawChild
1768
- const existing = element[key]
1769
-
1770
- if (existing && existing.state) {
1771
- // Patch the existing store with the new item's fields via
1772
- // `state.replace(obj)` — unlike `update` (which only merges
1773
- // new keys) `replace` also REMOVES any prior keys that
1774
- // aren't on the new object. Critical for list reconcilers:
1775
- // without replace semantics, a row that went from parent
1776
- // summary → sub-row would inherit the old `_isParent: true`
1777
- // flag and render both roles simultaneously.
1778
- //
1779
- // Signals fire for added / changed / removed keys, so the
1780
- // child's `text` / `hide` / `style` gates re-evaluate and
1781
- // the DOM node (+ any focused input inside) stays untouched.
1782
- if (rawChild && typeof rawChild === 'object') {
1783
- const st = existing.state
1784
- if (typeof st.replace === 'function') {
1785
- st.replace(rawChild)
1786
- } else if (typeof st.update === 'function') {
1787
- // Fallback for stores without replace(): emulate it by
1788
- // nulling keys not in the new object, then update().
1789
- for (const k2 in st) {
1790
- if (k2 === '__element' || k2 === 'root' || k2 === 'parent' || k2 === 'update') continue
1791
- if (!Object.prototype.hasOwnProperty.call(rawChild, k2)) {
1792
- try { st[k2] = undefined } catch {}
1793
- }
1794
- }
1795
- st.update(rawChild)
1796
- } else {
1797
- for (const k2 in rawChild) st[k2] = rawChild[k2]
1798
- }
1799
- }
1800
- continue
1801
- }
1802
-
1803
- // New child — build the childDef the same way renderChildren does,
1804
- // then create + append.
1805
- let stateVal = rawChild
1806
- if (typeof rawChild === 'string' || typeof rawChild === 'number') {
1807
- stateVal = { value: rawChild, text: rawChild }
1808
- }
1809
- const childProps = entry.childProps
1810
- let childDef = childProps ? { ...childProps, state: stateVal } : { state: stateVal }
1811
-
1812
- if (childExtends && !childDef.ignoreChildExtends) {
1813
- if (childDef.extends) {
1814
- const own = Array.isArray(childDef.extends) ? childDef.extends : [childDef.extends]
1815
- const parent = Array.isArray(childExtends) ? childExtends : [childExtends]
1816
- childDef = { ...childDef, extends: [...parent, ...own] }
1817
- } else {
1818
- childDef = { extends: childExtends, ...childDef }
1819
- }
1820
- }
1821
-
1822
- const child = create(childDef, element, key, options)
1823
- if (child?.node && fragment) fragment.appendChild(child.node)
1824
- }
1825
- if (fragment && fragment.childNodes.length && element.node) {
1826
- element.node.appendChild(fragment)
1827
- }
1828
-
1829
- // Keep __children in the new order for stable future reconciliations.
1830
- ref.__children = newOrder
1831
- return
1832
- }
1833
-
1834
- // Fallback: full rebuild (non-array results, or childrenAs not set).
1835
- for (const key of [...ref.__children]) {
1836
- const child = element[key]
1837
- if (child?.remove) child.remove()
1838
- delete element[key]
1839
- }
1840
- ref.__children = []
1841
- renderChildren(element, result, options)
1842
- }, { depth: ref.path.length })
1843
- ref.__effects.push(effect)
1844
- }
1845
-
1846
- renderChildren(element, children || content, options)
1847
-
1848
- if (children || content) return
1849
-
1850
- // Iterate element properties for child components (Uppercase keys)
1851
- const components = element.context?.components || {}
1852
-
1853
- // v3 compat: resolve XxxExtends shorthand
1854
- // Pattern: StarIconExtends: 'Icon', name: 'star', fontSize: 'X2'
1855
- // Becomes: StarIcon: { extends: 'Icon', name: 'star', fontSize: 'X2' }
1856
- {
1857
- const keys = Object.keys(element)
1858
- for (let i = 0; i < keys.length; i++) {
1859
- const prop = keys[i]
1860
- if (!prop.endsWith('Extends') || typeof element[prop] !== 'string') continue
1861
- const childName = prop.slice(0, -7)
1862
- if (!childName || childName.charCodeAt(0) < 65 || childName.charCodeAt(0) > 90) continue
1863
-
1864
- const childDef = { extends: element[prop] }
1865
- delete element[prop]
1866
-
1867
- // Collect subsequent lowercase properties until next Uppercase key or end
1868
- for (let j = i + 1; j < keys.length; j++) {
1869
- const nextKey = keys[j]
1870
- const fc = nextKey.charCodeAt(0)
1871
- if (fc >= 65 && fc <= 90) break // Stop at next child/component key
1872
- if (REGISTRY.has(nextKey)) break // Stop at framework keys
1873
- if (typeof element[nextKey] === 'function' && (nextKey.startsWith('on') || nextKey.startsWith('.'))) break
1874
- const val = element[nextKey]
1875
- if (val !== undefined) {
1876
- childDef[nextKey] = val
1877
- delete element[nextKey]
1878
- }
1879
- }
1880
-
1881
- // Merge with existing child def if present
1882
- if (element[childName] && typeof element[childName] === 'object') {
1883
- element[childName] = { ...childDef, ...element[childName] }
1884
- } else {
1885
- element[childName] = childDef
1886
- }
1887
- }
1888
- }
1889
-
1890
- for (const prop in element) {
1891
- if (!Object.prototype.hasOwnProperty.call(element, prop)) continue
1892
- if (REGISTRY.has(prop)) continue
1893
- if (typeof element[prop] !== 'object' || element[prop] === null) continue
1894
-
1895
- const firstChar = prop.charCodeAt(0)
1896
- if (firstChar < 65 || firstChar > 90) continue
1897
-
1898
- let childDef = element[prop]
1899
- if (childDef.__ref) continue // Already created
1900
-
1901
- // Auto-extend from context component if key matches and no extends set
1902
- if (!childDef.extends && !childDef.extend) {
1903
- const comp = components[prop] || components[`smbls.${prop}`]
1904
- if (comp) {
1905
- childDef = { extends: comp, ...childDef }
1906
- }
1907
- }
1908
-
1909
- // Apply childProps (e.g. fontSize, padding from parent's childProps)
1910
- if (element.childProps) {
1911
- childDef = { ...element.childProps, ...childDef }
1912
- }
1913
-
1914
- if (childExtends && !childDef.ignoreChildExtends) {
1915
- if (childDef.extends) {
1916
- const own = Array.isArray(childDef.extends) ? childDef.extends : [childDef.extends]
1917
- const parent = Array.isArray(childExtends) ? childExtends : [childExtends]
1918
- childDef = { ...childDef, extends: [...parent, ...own] }
1919
- } else {
1920
- childDef = { extends: childExtends, ...childDef }
1921
- }
1922
- }
1923
-
1924
- create(childDef, element, prop, options)
1925
- }
1926
- }
1927
-
1928
- // --- DOM Assignment ---
1929
-
1930
- function assignNode(element, parent, attachOptions) {
1931
- const { node } = element
1932
- if (!node) return
1933
-
1934
- // Expose DOMQL ref on DOM node in dev mode (use $0.ref in DevTools)
1935
- if (process.env.NODE_ENV !== 'production') {
1936
- node.ref = element
1937
- }
1938
-
1939
- // Support both DOMQL elements (.node) and raw DOM nodes (root case)
1940
- const parentNode = parent?.node || (parent?.nodeType ? parent : null)
1941
- if (!parentNode) return
1942
-
1943
- if (attachOptions?.position === 'before' && attachOptions.node) {
1944
- parentNode.insertBefore(node, attachOptions.node)
1945
- } else if (attachOptions?.position === 'after' && attachOptions.node) {
1946
- const nextSib = attachOptions.node.nextSibling
1947
- if (nextSib) {
1948
- parentNode.insertBefore(node, nextSib)
1949
- } else {
1950
- parentNode.appendChild(node)
1951
- }
1952
- } else {
1953
- parentNode.appendChild(node)
1954
- }
1955
- }
1956
-
1957
- function triggerLifecycle(eventSuffix, element, options) {
1958
- const hookName = eventSuffix.charAt(0).toLowerCase() + eventSuffix.slice(1)
1959
- if (element.context?.plugins?.length) runPluginHook(hookName, element, options)
1960
-
1961
- const key = 'on' + eventSuffix
1962
- const handler = element[key]
1963
- if (typeof handler === 'function') {
1964
- // Scope config to this element's context so lifecycle handlers that
1965
- // inject CSS or resolve tokens use the right app. The document is
1966
- // stored on the config, so getActiveDocument() resolves it too.
1967
- const ds = element.context?.designSystem
1968
- if (ds) pushConfig(ds)
1969
- try {
1970
- const result = handler(element, element.state, element.context, options)
1971
- if (result === false) return false
1972
- } catch (err) {
1973
- // Route the error through the plugin error hook so observers
1974
- // (e.g. the analyze plugin) see it with full element context.
1975
- // Falls back to console.error when no plugin captures it.
1976
- const handled = element.context?.plugins?.length &&
1977
- runPluginHook('error', element, { hook: key, error: err })
1978
- if (!handled) console.error(`[DOMQL] Lifecycle "${key}" error:`, err)
1979
- if (element.context?.strictMode) throw err
1980
- } finally {
1981
- if (ds) popConfig()
1982
- }
1983
- }
1984
- return true
1985
- }
1986
-
1987
- export default create