@fictjs/runtime 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +17 -0
  2. package/dist/index.cjs +4224 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +1572 -0
  5. package/dist/index.d.ts +1572 -0
  6. package/dist/index.dev.js +4240 -0
  7. package/dist/index.dev.js.map +1 -0
  8. package/dist/index.js +4133 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/jsx-dev-runtime.cjs +44 -0
  11. package/dist/jsx-dev-runtime.cjs.map +1 -0
  12. package/dist/jsx-dev-runtime.js +14 -0
  13. package/dist/jsx-dev-runtime.js.map +1 -0
  14. package/dist/jsx-runtime.cjs +44 -0
  15. package/dist/jsx-runtime.cjs.map +1 -0
  16. package/dist/jsx-runtime.js +14 -0
  17. package/dist/jsx-runtime.js.map +1 -0
  18. package/dist/slim.cjs +3384 -0
  19. package/dist/slim.cjs.map +1 -0
  20. package/dist/slim.d.cts +475 -0
  21. package/dist/slim.d.ts +475 -0
  22. package/dist/slim.js +3335 -0
  23. package/dist/slim.js.map +1 -0
  24. package/package.json +68 -0
  25. package/src/binding.ts +2127 -0
  26. package/src/constants.ts +456 -0
  27. package/src/cycle-guard.ts +134 -0
  28. package/src/devtools.ts +17 -0
  29. package/src/dom.ts +683 -0
  30. package/src/effect.ts +83 -0
  31. package/src/error-boundary.ts +118 -0
  32. package/src/hooks.ts +72 -0
  33. package/src/index.ts +184 -0
  34. package/src/jsx-dev-runtime.ts +2 -0
  35. package/src/jsx-runtime.ts +2 -0
  36. package/src/jsx.ts +786 -0
  37. package/src/lifecycle.ts +273 -0
  38. package/src/list-helpers.ts +619 -0
  39. package/src/memo.ts +14 -0
  40. package/src/node-ops.ts +185 -0
  41. package/src/props.ts +212 -0
  42. package/src/reconcile.ts +151 -0
  43. package/src/ref.ts +25 -0
  44. package/src/scheduler.ts +12 -0
  45. package/src/signal.ts +1278 -0
  46. package/src/slim.ts +68 -0
  47. package/src/store.ts +210 -0
  48. package/src/suspense.ts +187 -0
  49. package/src/transition.ts +128 -0
  50. package/src/types.ts +172 -0
  51. package/src/versioned-signal.ts +58 -0
package/src/dom.ts ADDED
@@ -0,0 +1,683 @@
1
+ /**
2
+ * Fict DOM Rendering System
3
+ *
4
+ * This module provides DOM rendering capabilities with reactive bindings.
5
+ * It transforms JSX virtual nodes into actual DOM elements, automatically
6
+ * setting up reactive updates for dynamic values.
7
+ *
8
+ * Key Features:
9
+ * - Reactive text content: `{count}` updates when count changes
10
+ * - Reactive attributes: `disabled={!isValid}` updates reactively
11
+ * - Reactive children: `{show && <Modal />}` handles conditionals
12
+ * - List rendering: `{items.map(...)}` with efficient keyed updates
13
+ */
14
+
15
+ import {
16
+ createTextBinding,
17
+ createAttributeBinding,
18
+ createStyleBinding,
19
+ createClassBinding,
20
+ createChildBinding,
21
+ bindEvent,
22
+ isReactive,
23
+ PRIMITIVE_PROXY,
24
+ type MaybeReactive,
25
+ type AttributeSetter,
26
+ type BindingHandle,
27
+ } from './binding'
28
+ import { Properties, ChildProperties, Aliases, getPropAlias, SVGNamespace } from './constants'
29
+ import { __fictPushContext, __fictPopContext } from './hooks'
30
+ import { Fragment } from './jsx'
31
+ import {
32
+ createRootContext,
33
+ destroyRoot,
34
+ flushOnMount,
35
+ handleError,
36
+ handleSuspend,
37
+ pushRoot,
38
+ popRoot,
39
+ registerRootCleanup,
40
+ getCurrentRoot,
41
+ } from './lifecycle'
42
+ import { createPropsProxy, unwrapProps } from './props'
43
+ import { untrack } from './scheduler'
44
+ import type { DOMElement, FictNode, FictVNode, RefObject } from './types'
45
+
46
+ // ============================================================================
47
+ // Main Render Function
48
+ // ============================================================================
49
+
50
+ /**
51
+ * Render a Fict view into a container element.
52
+ *
53
+ * @param view - A function that returns the view to render
54
+ * @param container - The DOM container to render into
55
+ * @returns A teardown function to unmount the view
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * const unmount = render(() => <App />, document.getElementById('root')!)
60
+ * // Later: unmount()
61
+ * ```
62
+ */
63
+ export function render(view: () => FictNode, container: HTMLElement): () => void {
64
+ const root = createRootContext()
65
+ const prev = pushRoot(root)
66
+ let dom: DOMElement
67
+ try {
68
+ const output = view()
69
+ // createElement must be called within the root context
70
+ // so that child components register their onMount callbacks correctly
71
+ dom = createElement(output)
72
+ } finally {
73
+ popRoot(prev)
74
+ }
75
+
76
+ container.replaceChildren(dom)
77
+ container.setAttribute('data-fict-fine-grained', '1')
78
+
79
+ flushOnMount(root)
80
+
81
+ const teardown = () => {
82
+ destroyRoot(root)
83
+ container.innerHTML = ''
84
+ }
85
+
86
+ return teardown
87
+ }
88
+
89
+ // ============================================================================
90
+ // Element Creation
91
+ // ============================================================================
92
+
93
+ /**
94
+ * Create a DOM element from a Fict node.
95
+ * This is the main entry point for converting virtual nodes to real DOM.
96
+ *
97
+ * Supports:
98
+ * - Native DOM nodes (passed through)
99
+ * - Null/undefined/false (empty text node)
100
+ * - Arrays (DocumentFragment)
101
+ * - Strings/numbers (text nodes)
102
+ * - Booleans (empty text node)
103
+ * - VNodes (components or HTML elements)
104
+ * - Reactive values (functions returning any of the above)
105
+ */
106
+ export function createElement(node: FictNode): DOMElement {
107
+ // Already a DOM node - pass through
108
+ if (node instanceof Node) {
109
+ return node
110
+ }
111
+
112
+ // Null/undefined/false - empty placeholder
113
+ if (node === null || node === undefined || node === false) {
114
+ return document.createTextNode('')
115
+ }
116
+
117
+ // Primitive proxy produced by keyed list binding
118
+ if (typeof node === 'object' && node !== null && !(node instanceof Node)) {
119
+ // Handle BindingHandle (createList, createConditional, etc)
120
+ if ('marker' in node) {
121
+ return createElement((node as { marker: unknown }).marker as FictNode)
122
+ }
123
+
124
+ const nodeRecord = node as unknown as Record<PropertyKey, unknown>
125
+ if (nodeRecord[PRIMITIVE_PROXY]) {
126
+ const primitiveGetter = nodeRecord[Symbol.toPrimitive]
127
+ const value =
128
+ typeof primitiveGetter === 'function' ? primitiveGetter.call(node, 'default') : node
129
+ return document.createTextNode(value == null || value === false ? '' : String(value))
130
+ }
131
+ }
132
+
133
+ // Array - create fragment
134
+ if (Array.isArray(node)) {
135
+ const frag = document.createDocumentFragment()
136
+ for (const child of node) {
137
+ appendChildNode(frag, child)
138
+ }
139
+ return frag
140
+ }
141
+
142
+ // Primitive values - text node
143
+ if (typeof node === 'string' || typeof node === 'number') {
144
+ return document.createTextNode(String(node))
145
+ }
146
+
147
+ if (typeof node === 'boolean') {
148
+ return document.createTextNode('')
149
+ }
150
+
151
+ // VNode
152
+ const vnode = node as FictVNode
153
+
154
+ // Function component
155
+ if (typeof vnode.type === 'function') {
156
+ const rawProps = unwrapProps(vnode.props ?? {}) as Record<string, unknown>
157
+ const baseProps =
158
+ vnode.key === undefined
159
+ ? rawProps
160
+ : new Proxy(rawProps, {
161
+ get(target, prop, receiver) {
162
+ if (prop === 'key') return vnode.key
163
+ return Reflect.get(target, prop, receiver)
164
+ },
165
+ has(target, prop) {
166
+ if (prop === 'key') return true
167
+ return prop in target
168
+ },
169
+ ownKeys(target) {
170
+ const keys = new Set(Reflect.ownKeys(target))
171
+ keys.add('key')
172
+ return Array.from(keys)
173
+ },
174
+ getOwnPropertyDescriptor(target, prop) {
175
+ if (prop === 'key') {
176
+ return { enumerable: true, configurable: true, value: vnode.key }
177
+ }
178
+ return Object.getOwnPropertyDescriptor(target, prop)
179
+ },
180
+ })
181
+
182
+ const props = createPropsProxy(baseProps)
183
+ try {
184
+ // Create a fresh hook context for this component instance.
185
+ // This preserves slot state across re-renders driven by __fictRender.
186
+ __fictPushContext()
187
+ const rendered = vnode.type(props)
188
+ __fictPopContext()
189
+ return createElement(rendered as FictNode)
190
+ } catch (err) {
191
+ __fictPopContext()
192
+ if (handleSuspend(err as any)) {
193
+ return document.createComment('fict:suspend')
194
+ }
195
+ handleError(err, { source: 'render', componentName: vnode.type.name })
196
+ throw err
197
+ }
198
+ }
199
+
200
+ // Fragment
201
+ if (vnode.type === Fragment) {
202
+ const frag = document.createDocumentFragment()
203
+ const children = vnode.props?.children as FictNode | FictNode[] | undefined
204
+ appendChildren(frag, children)
205
+ return frag
206
+ }
207
+
208
+ // HTML Element
209
+ const tagName = typeof vnode.type === 'string' ? vnode.type : 'div'
210
+ const el = document.createElement(tagName)
211
+ applyProps(el, vnode.props ?? {})
212
+ return el
213
+ }
214
+
215
+ /**
216
+ * Create a template cloning factory from an HTML string.
217
+ * Used by the compiler for efficient DOM generation.
218
+ *
219
+ * @param html - The HTML string to create a template from
220
+ * @param isImportNode - Use importNode for elements like img/iframe
221
+ * @param isSVG - Whether the template is SVG content
222
+ * @param isMathML - Whether the template is MathML content
223
+ */
224
+ export function template(
225
+ html: string,
226
+ isImportNode?: boolean,
227
+ isSVG?: boolean,
228
+ isMathML?: boolean,
229
+ ): () => Node {
230
+ let node: Node | null = null
231
+
232
+ const create = (): Node => {
233
+ const t = isMathML
234
+ ? document.createElementNS('http://www.w3.org/1998/Math/MathML', 'template')
235
+ : document.createElement('template')
236
+ t.innerHTML = html
237
+
238
+ if (isSVG) {
239
+ // For SVG, get the nested content
240
+ return (t as HTMLTemplateElement).content.firstChild!.firstChild!
241
+ }
242
+ if (isMathML) {
243
+ return t.firstChild!
244
+ }
245
+ return (t as HTMLTemplateElement).content.firstChild!
246
+ }
247
+
248
+ // Create the cloning function
249
+ const fn = isImportNode
250
+ ? () => untrack(() => document.importNode(node || (node = create()), true))
251
+ : () => (node || (node = create())).cloneNode(true)
252
+
253
+ // Add cloneNode property for compatibility
254
+ ;(fn as { cloneNode?: typeof fn }).cloneNode = fn
255
+
256
+ return fn
257
+ }
258
+
259
+ // ============================================================================
260
+ // Child Node Handling
261
+ // ============================================================================
262
+
263
+ // Use the comprehensive Properties set from constants for property binding
264
+ // These properties must update via DOM property semantics rather than attributes
265
+
266
+ /**
267
+ * Check if a value is a runtime binding handle
268
+ */
269
+ function isBindingHandle(node: unknown): node is BindingHandle {
270
+ return (
271
+ node !== null &&
272
+ typeof node === 'object' &&
273
+ 'marker' in node &&
274
+ 'dispose' in node &&
275
+ typeof (node as BindingHandle).dispose === 'function'
276
+ )
277
+ }
278
+
279
+ /**
280
+ * Append a child node to a parent, handling all node types including reactive values.
281
+ */
282
+ function appendChildNode(parent: HTMLElement | DocumentFragment, child: FictNode): void {
283
+ // Skip nullish values
284
+ if (child === null || child === undefined || child === false) {
285
+ return
286
+ }
287
+
288
+ // Handle BindingHandle (recursive)
289
+ if (isBindingHandle(child)) {
290
+ appendChildNode(parent, child.marker)
291
+ // Flush pending nodes now that markers are in the DOM
292
+ child.flush?.()
293
+ return
294
+ }
295
+
296
+ // Handle getter function (recursive)
297
+ if (typeof child === 'function' && (child as () => FictNode).length === 0) {
298
+ const childGetter = child as () => FictNode
299
+ createChildBinding(parent as HTMLElement | DocumentFragment, childGetter, createElement)
300
+ return
301
+ }
302
+
303
+ // Static child - create element and append
304
+ if (Array.isArray(child)) {
305
+ for (const item of child) {
306
+ appendChildNode(parent, item)
307
+ }
308
+ return
309
+ }
310
+
311
+ // Cast to Node for remaining logic
312
+ let domNode: Node
313
+ if (typeof child !== 'object' || child === null) {
314
+ domNode = document.createTextNode(String(child ?? ''))
315
+ } else {
316
+ domNode = createElement(child as any) as Node
317
+ }
318
+
319
+ // Handle DocumentFragment manually to avoid JSDOM issues
320
+ if (domNode.nodeType === 11) {
321
+ const children = Array.from(domNode.childNodes)
322
+ for (const node of children) {
323
+ appendChildNode(parent, node)
324
+ }
325
+ return
326
+ }
327
+
328
+ if (domNode.ownerDocument !== parent.ownerDocument && parent.ownerDocument) {
329
+ parent.ownerDocument.adoptNode(domNode)
330
+ }
331
+
332
+ try {
333
+ parent.appendChild(domNode)
334
+ } catch (e: any) {
335
+ if (parent.ownerDocument) {
336
+ const clone = parent.ownerDocument.importNode(domNode, true)
337
+ parent.appendChild(clone)
338
+ return
339
+ }
340
+ throw e
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Append multiple children, handling arrays and nested structures.
346
+ */
347
+ function appendChildren(
348
+ parent: HTMLElement | DocumentFragment,
349
+ children: FictNode | FictNode[] | undefined,
350
+ ): void {
351
+ if (children === undefined) return
352
+
353
+ if (Array.isArray(children)) {
354
+ for (const child of children) {
355
+ appendChildren(parent, child)
356
+ }
357
+ return
358
+ }
359
+
360
+ appendChildNode(parent, children)
361
+ }
362
+
363
+ // ============================================================================
364
+ // Ref Handling
365
+ // ============================================================================
366
+
367
+ /**
368
+ * Apply a ref to an element, supporting both callback and object refs.
369
+ * Both types are automatically cleaned up on unmount.
370
+ */
371
+ function applyRef(el: HTMLElement, value: unknown): void {
372
+ if (typeof value === 'function') {
373
+ // Callback ref
374
+ const refFn = value as (el: HTMLElement | null) => void
375
+ refFn(el)
376
+
377
+ // Match React behavior: call ref(null) on unmount
378
+ if (getCurrentRoot()) {
379
+ registerRootCleanup(() => {
380
+ refFn(null)
381
+ })
382
+ }
383
+ } else if (value && typeof value === 'object' && 'current' in value) {
384
+ // Object ref
385
+ const refObj = value as RefObject<HTMLElement>
386
+ refObj.current = el
387
+
388
+ // Auto-cleanup on unmount
389
+ if (getCurrentRoot()) {
390
+ registerRootCleanup(() => {
391
+ refObj.current = null
392
+ })
393
+ }
394
+ }
395
+ }
396
+
397
+ // ============================================================================
398
+ // Props Handling
399
+ // ============================================================================
400
+
401
+ /**
402
+ * Apply props to an HTML element, setting up reactive bindings as needed.
403
+ * Uses comprehensive property constants for correct attribute/property handling.
404
+ */
405
+ function applyProps(el: HTMLElement, props: Record<string, unknown>, isSVG = false): void {
406
+ props = unwrapProps(props)
407
+ const tagName = el.tagName
408
+
409
+ // Check if this is a custom element
410
+ const isCE = tagName.includes('-') || 'is' in props
411
+
412
+ for (const [key, value] of Object.entries(props)) {
413
+ if (key === 'children') continue
414
+
415
+ // Ref handling
416
+ if (key === 'ref') {
417
+ applyRef(el, value)
418
+ continue
419
+ }
420
+
421
+ // Event handling with delegation support
422
+ if (isEventKey(key)) {
423
+ bindEvent(
424
+ el,
425
+ eventNameFromProp(key),
426
+ value as MaybeReactive<EventListenerOrEventListenerObject | null | undefined>,
427
+ )
428
+ continue
429
+ }
430
+
431
+ // Explicit on: namespace for non-delegated events
432
+ if (key.slice(0, 3) === 'on:') {
433
+ bindEvent(
434
+ el,
435
+ key.slice(3),
436
+ value as MaybeReactive<EventListenerOrEventListenerObject | null | undefined>,
437
+ false, // Non-delegated
438
+ )
439
+ continue
440
+ }
441
+
442
+ // Capture events
443
+ if (key.slice(0, 10) === 'oncapture:') {
444
+ bindEvent(
445
+ el,
446
+ key.slice(10),
447
+ value as MaybeReactive<EventListenerOrEventListenerObject | null | undefined>,
448
+ true, // Capture
449
+ )
450
+ continue
451
+ }
452
+
453
+ // Class/ClassName
454
+ if (key === 'class' || key === 'className') {
455
+ createClassBinding(el, value as MaybeReactive<string | Record<string, boolean> | null>)
456
+ continue
457
+ }
458
+
459
+ // classList for object-style class binding
460
+ if (key === 'classList') {
461
+ createClassBinding(el, value as MaybeReactive<Record<string, boolean> | null>)
462
+ continue
463
+ }
464
+
465
+ // Style
466
+ if (key === 'style') {
467
+ createStyleBinding(
468
+ el,
469
+ value as MaybeReactive<string | Record<string, string | number> | null>,
470
+ )
471
+ continue
472
+ }
473
+
474
+ // dangerouslySetInnerHTML
475
+ if (key === 'dangerouslySetInnerHTML' && value && typeof value === 'object') {
476
+ const htmlValue = (value as { __html?: string }).__html
477
+ if (htmlValue !== undefined) {
478
+ if (isReactive(htmlValue)) {
479
+ createAttributeBinding(el, 'innerHTML', htmlValue as () => unknown, setInnerHTML)
480
+ } else {
481
+ el.innerHTML = htmlValue
482
+ }
483
+ }
484
+ continue
485
+ }
486
+
487
+ // Child properties (innerHTML, textContent, etc.)
488
+ if (ChildProperties.has(key)) {
489
+ createAttributeBinding(el, key, value as MaybeReactive<unknown>, setProperty)
490
+ continue
491
+ }
492
+
493
+ // Forced attribute via attr: prefix
494
+ if (key.slice(0, 5) === 'attr:') {
495
+ createAttributeBinding(el, key.slice(5), value as MaybeReactive<unknown>, setAttribute)
496
+ continue
497
+ }
498
+
499
+ // Forced boolean attribute via bool: prefix
500
+ if (key.slice(0, 5) === 'bool:') {
501
+ createAttributeBinding(el, key.slice(5), value as MaybeReactive<unknown>, setBoolAttribute)
502
+ continue
503
+ }
504
+
505
+ // Forced property via prop: prefix
506
+ if (key.slice(0, 5) === 'prop:') {
507
+ createAttributeBinding(el, key.slice(5), value as MaybeReactive<unknown>, setProperty)
508
+ continue
509
+ }
510
+
511
+ // Check for property alias (element-specific mappings)
512
+ const propAlias = !isSVG ? getPropAlias(key, tagName) : undefined
513
+
514
+ // Handle properties and element-specific attributes
515
+ if (propAlias || (!isSVG && Properties.has(key)) || (isCE && !isSVG)) {
516
+ const propName = propAlias || key
517
+ // Custom elements use toPropertyName conversion
518
+ if (isCE && !Properties.has(key)) {
519
+ createAttributeBinding(
520
+ el,
521
+ toPropertyName(propName),
522
+ value as MaybeReactive<unknown>,
523
+ setProperty,
524
+ )
525
+ } else {
526
+ createAttributeBinding(el, propName, value as MaybeReactive<unknown>, setProperty)
527
+ }
528
+ continue
529
+ }
530
+
531
+ // SVG namespaced attributes (xlink:href, xml:lang, etc.)
532
+ if (isSVG && key.indexOf(':') > -1) {
533
+ const [prefix, name] = key.split(':')
534
+ const ns = SVGNamespace[prefix!]
535
+ if (ns) {
536
+ createAttributeBinding(el, key, value as MaybeReactive<unknown>, (el, _key, val) =>
537
+ setAttributeNS(el, ns, name!, val),
538
+ )
539
+ continue
540
+ }
541
+ }
542
+
543
+ // Regular attributes (potentially reactive)
544
+ // Apply alias mapping (className -> class, htmlFor -> for)
545
+ const attrName = Aliases[key] || key
546
+ createAttributeBinding(el, attrName, value as MaybeReactive<unknown>, setAttribute)
547
+ }
548
+
549
+ // Handle children
550
+ const children = props.children as FictNode | FictNode[] | undefined
551
+ appendChildren(el, children)
552
+ }
553
+
554
+ /**
555
+ * Convert kebab-case to camelCase for custom element properties
556
+ */
557
+ function toPropertyName(name: string): string {
558
+ return name.toLowerCase().replace(/-([a-z])/g, (_, w) => w.toUpperCase())
559
+ }
560
+
561
+ // ============================================================================
562
+ // Attribute Setters
563
+ // ============================================================================
564
+
565
+ /**
566
+ * Set an attribute on an element, handling various value types.
567
+ */
568
+ const setAttribute: AttributeSetter = (el: HTMLElement, key: string, value: unknown): void => {
569
+ // Remove attribute for nullish/false values
570
+ if (value === undefined || value === null || value === false) {
571
+ el.removeAttribute(key)
572
+ return
573
+ }
574
+
575
+ // Boolean true -> empty string attribute
576
+ if (value === true) {
577
+ el.setAttribute(key, '')
578
+ return
579
+ }
580
+
581
+ // Primitive values
582
+ const valueType = typeof value
583
+ if (valueType === 'string' || valueType === 'number') {
584
+ el.setAttribute(key, String(value))
585
+ return
586
+ }
587
+
588
+ // DOM property (for cases like `value`, `checked`, etc.)
589
+ if (key in el) {
590
+ ;(el as unknown as Record<string, unknown>)[key] = value
591
+ return
592
+ }
593
+
594
+ // Fallback: set as attribute
595
+ el.setAttribute(key, String(value))
596
+ }
597
+
598
+ /**
599
+ * Set a property on an element, ensuring nullish values clear sensibly.
600
+ */
601
+ const setProperty: AttributeSetter = (el: HTMLElement, key: string, value: unknown): void => {
602
+ if (value === undefined || value === null) {
603
+ const fallback = key === 'checked' || key === 'selected' ? false : ''
604
+ ;(el as unknown as Record<string, unknown>)[key] = fallback
605
+ return
606
+ }
607
+
608
+ // Handle style object binding style={{ color: 'red' }}
609
+ if (key === 'style' && typeof value === 'object' && value !== null) {
610
+ for (const k in value as Record<string, string>) {
611
+ const v = (value as Record<string, string>)[k]
612
+ if (v !== undefined) {
613
+ ;(el.style as unknown as Record<string, string>)[k] = String(v)
614
+ }
615
+ }
616
+ return
617
+ }
618
+
619
+ ;(el as unknown as Record<string, unknown>)[key] = value as unknown
620
+ }
621
+
622
+ /**
623
+ * Set innerHTML on an element (used for dangerouslySetInnerHTML)
624
+ */
625
+ const setInnerHTML: AttributeSetter = (el: HTMLElement, _key: string, value: unknown): void => {
626
+ el.innerHTML = value == null ? '' : String(value)
627
+ }
628
+
629
+ /**
630
+ * Set a boolean attribute on an element (empty string when true, removed when false)
631
+ */
632
+ const setBoolAttribute: AttributeSetter = (el: HTMLElement, key: string, value: unknown): void => {
633
+ if (value) {
634
+ el.setAttribute(key, '')
635
+ } else {
636
+ el.removeAttribute(key)
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Set an attribute with a namespace (for SVG xlink:href, etc.)
642
+ */
643
+ function setAttributeNS(el: HTMLElement, namespace: string, name: string, value: unknown): void {
644
+ if (value == null) {
645
+ el.removeAttributeNS(namespace, name)
646
+ } else {
647
+ el.setAttributeNS(namespace, name, String(value))
648
+ }
649
+ }
650
+
651
+ // ============================================================================
652
+ // Event Handling Utilities
653
+ // ============================================================================
654
+
655
+ /**
656
+ * Check if a prop key is an event handler (starts with "on")
657
+ */
658
+ function isEventKey(key: string): boolean {
659
+ return key.startsWith('on') && key.length > 2 && key[2]!.toUpperCase() === key[2]
660
+ }
661
+
662
+ /**
663
+ * Convert a React-style event prop to a DOM event name
664
+ * e.g., "onClick" -> "click", "onMouseDown" -> "mousedown"
665
+ */
666
+ function eventNameFromProp(key: string): string {
667
+ return key.slice(2).toLowerCase()
668
+ }
669
+
670
+ // ============================================================================
671
+ // Exports for Advanced Usage
672
+ // ============================================================================
673
+
674
+ export {
675
+ createTextBinding,
676
+ createChildBinding,
677
+ createAttributeBinding,
678
+ createStyleBinding,
679
+ createClassBinding,
680
+ isReactive,
681
+ }
682
+
683
+ export type { BindingHandle, MaybeReactive }