@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.
- package/README.md +17 -0
- package/dist/index.cjs +4224 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1572 -0
- package/dist/index.d.ts +1572 -0
- package/dist/index.dev.js +4240 -0
- package/dist/index.dev.js.map +1 -0
- package/dist/index.js +4133 -0
- package/dist/index.js.map +1 -0
- package/dist/jsx-dev-runtime.cjs +44 -0
- package/dist/jsx-dev-runtime.cjs.map +1 -0
- package/dist/jsx-dev-runtime.js +14 -0
- package/dist/jsx-dev-runtime.js.map +1 -0
- package/dist/jsx-runtime.cjs +44 -0
- package/dist/jsx-runtime.cjs.map +1 -0
- package/dist/jsx-runtime.js +14 -0
- package/dist/jsx-runtime.js.map +1 -0
- package/dist/slim.cjs +3384 -0
- package/dist/slim.cjs.map +1 -0
- package/dist/slim.d.cts +475 -0
- package/dist/slim.d.ts +475 -0
- package/dist/slim.js +3335 -0
- package/dist/slim.js.map +1 -0
- package/package.json +68 -0
- package/src/binding.ts +2127 -0
- package/src/constants.ts +456 -0
- package/src/cycle-guard.ts +134 -0
- package/src/devtools.ts +17 -0
- package/src/dom.ts +683 -0
- package/src/effect.ts +83 -0
- package/src/error-boundary.ts +118 -0
- package/src/hooks.ts +72 -0
- package/src/index.ts +184 -0
- package/src/jsx-dev-runtime.ts +2 -0
- package/src/jsx-runtime.ts +2 -0
- package/src/jsx.ts +786 -0
- package/src/lifecycle.ts +273 -0
- package/src/list-helpers.ts +619 -0
- package/src/memo.ts +14 -0
- package/src/node-ops.ts +185 -0
- package/src/props.ts +212 -0
- package/src/reconcile.ts +151 -0
- package/src/ref.ts +25 -0
- package/src/scheduler.ts +12 -0
- package/src/signal.ts +1278 -0
- package/src/slim.ts +68 -0
- package/src/store.ts +210 -0
- package/src/suspense.ts +187 -0
- package/src/transition.ts +128 -0
- package/src/types.ts +172 -0
- 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 }
|