fusee-framework 1.0.1 → 1.1.0

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.
@@ -1,369 +1,381 @@
1
- import { effect, batch } from './signal.js'
1
+ import { signal, effect, batch } from './signal.js'
2
2
  import { evaluateExpression } from './evaluator.js'
3
3
  import { compileNode } from './compiler.js'
4
4
 
5
5
  export function processDirectives(root, context, components, effects) {
6
- processFor(root, context, components, effects)
7
- processIf(root, context, components, effects)
6
+ if (processFor(root, context, components, effects)) return true
7
+ if (processIf(root, context, components, effects)) return true
8
+
8
9
  processShow(root, context, effects)
9
10
  processModel(root, context, effects)
10
11
  processRefs(root, context)
11
12
  processHtml(root, context, effects)
12
13
  processText(root, context, effects)
13
- processEvents(root, context)
14
+ processEvents(root, context, effects)
14
15
  processCloak(root)
16
+
17
+ return false
15
18
  }
16
19
 
17
- export function processRefs(root, context) {
18
- const refEls = root.querySelectorAll('[f-ref]')
19
- for (const el of refEls) {
20
- if (!el.parentNode) continue
20
+ export function processRefs(el, context) {
21
+ if (el.nodeType !== 1 || !el.hasAttribute('f-ref')) return
21
22
 
22
- const refName = el.getAttribute('f-ref')
23
- el.removeAttribute('f-ref')
23
+ const refName = el.getAttribute('f-ref')
24
+ el.removeAttribute('f-ref')
24
25
 
25
- const refVar = context[refName]
26
- if (typeof refVar === 'function' && refVar.isSignal && !refVar.readonly) {
27
- refVar(el)
28
- } else {
29
- const reason = refVar?.readonly ? 'it is read-only (computed)' : 'it is not a signal'
30
- console.warn(`[framework] f-ref requires a writable signal. Target "${refName}" is invalid: ${reason}`)
31
- }
26
+ const refVar = context[refName]
27
+ if (typeof refVar === 'function' && refVar.isSignal && !refVar.readonly) {
28
+ refVar(el)
29
+ } else {
30
+ const reason = refVar?.readonly ? 'it is read-only (computed)' : 'it is not a signal'
31
+ console.warn(`[framework] f-ref requires a writable signal. Target "${refName}" is invalid: ${reason}`)
32
32
  }
33
33
  }
34
34
 
35
- export function processModel(root, context, effects) {
36
- const modelEls = root.querySelectorAll('[f-model]')
37
- for (const el of modelEls) {
38
- if (!el.parentNode) continue
35
+ export function processModel(el, context, effects) {
36
+ if (el.nodeType !== 1 || !el.hasAttribute('f-model')) return
39
37
 
40
- const expr = el.getAttribute('f-model')
41
- el.removeAttribute('f-model')
38
+ const expr = el.getAttribute('f-model')
39
+ el.removeAttribute('f-model')
42
40
 
43
- const signalMethod = context[expr]
44
- if (typeof signalMethod === 'function' && signalMethod.isSignal && !signalMethod.readonly) {
45
- effects.push(effect(() => {
46
- const val = signalMethod()
47
- if (el.type === 'checkbox') {
48
- el.checked = !!val
49
- } else {
50
- el.value = val ?? ''
51
- }
52
- }))
41
+ const signalMethod = context[expr]
42
+ if (typeof signalMethod === 'function' && signalMethod.isSignal && !signalMethod.readonly) {
43
+ effects.push(effect(() => {
44
+ const val = signalMethod()
45
+ if (el.type === 'checkbox') {
46
+ el.checked = !!val
47
+ } else {
48
+ el.value = val ?? ''
49
+ }
50
+ }))
53
51
 
54
- const eventName = (el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'radio') ? 'change' : 'input'
55
- el.addEventListener(eventName, () => {
56
- const newVal = el.type === 'checkbox' ? el.checked : el.value
57
- batch(() => signalMethod(newVal))
58
- })
59
- } else {
60
- const reason = signalMethod?.readonly ? 'it is read-only (computed)' : 'it is not a signal'
61
- console.warn(`[framework] f-model requires a writable signal. Expression "${expr}" is invalid: ${reason}`)
52
+ const eventName = (el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'radio') ? 'change' : 'input'
53
+ const handler = () => {
54
+ const newVal = el.type === 'checkbox' ? el.checked : el.value
55
+ batch(() => signalMethod(newVal))
62
56
  }
57
+ el.addEventListener(eventName, handler)
58
+ effects.push(() => el.removeEventListener(eventName, handler))
59
+ } else {
60
+ const reason = signalMethod?.readonly ? 'it is read-only (computed)' : 'it is not a signal'
61
+ console.warn(`[framework] f-model requires a writable signal. Expression "${expr}" is invalid: ${reason}`)
63
62
  }
64
63
  }
65
64
 
66
- export function processFor(root, context, components, effects) {
67
- const forEls = root.querySelectorAll('[f-for]')
68
- for (const el of forEls) {
69
- if (!el.parentNode) continue
65
+ export function processFor(el, context, components, effects) {
66
+ if (el.nodeType !== 1 || !el.hasAttribute('f-for')) return false
70
67
 
71
- const expr = el.getAttribute('f-for')
72
- el.removeAttribute('f-for')
68
+ const expr = el.getAttribute('f-for')
69
+ el.removeAttribute('f-for')
73
70
 
74
- const match = expr.match(/^\s*(?:(?:\(\s*([a-zA-Z_$][0-9a-zA-Z_$]*)(?:\s*,\s*([a-zA-Z_$][0-9a-zA-Z_$]*))?(?:\s*,\s*([a-zA-Z_$][0-9a-zA-Z_$]*))?\s*\))|([a-zA-Z_$][0-9a-zA-Z_$]*))\s+in\s+(.+)\s*$/)
75
- if (!match) {
76
- console.warn(`[framework] Invalid f-for expression: "${expr}"`)
77
- continue
78
- }
71
+ const match = expr.match(/^\s*(?:(?:\(\s*([a-zA-Z_$][0-9a-zA-Z_$]*)(?:\s*,\s*([a-zA-Z_$][0-9a-zA-Z_$]*))?(?:\s*,\s*([a-zA-Z_$][0-9a-zA-Z_$]*))?\s*\))|([a-zA-Z_$][0-9a-zA-Z_$]*))\s+in\s+(.+)\s*$/)
72
+ if (!match) {
73
+ console.warn(`[framework] Invalid f-for expression: "${expr}"`)
74
+ return false
75
+ }
79
76
 
80
- const itemName = match[4] || match[1]
81
- const keyName = match[2]
82
- const indexName = match[3]
83
- const arrayExpr = match[5]
77
+ const itemName = match[4] || match[1]
78
+ const keyName = match[2]
79
+ const indexName = match[3]
80
+ const arrayExpr = match[5]
84
81
 
85
- const keyExpr = el.getAttribute('data-bind-key') || el.getAttribute(':key')
86
- if (keyExpr) {
87
- el.removeAttribute('data-bind-key')
88
- el.removeAttribute(':key')
89
- }
82
+ const keyExpr = el.getAttribute('data-bind-key') || el.getAttribute(':key')
83
+ if (keyExpr) {
84
+ el.removeAttribute('data-bind-key')
85
+ el.removeAttribute(':key')
86
+ }
90
87
 
91
- const anchor = document.createComment('f-for')
92
- const parent = el.parentNode
93
- parent.insertBefore(anchor, el)
88
+ const anchor = document.createComment('f-for')
89
+ const parent = el.parentNode
90
+ parent.insertBefore(anchor, el)
94
91
 
95
- const templateNode = el.cloneNode(true)
96
- parent.removeChild(el)
92
+ const templateNode = el.cloneNode(true)
93
+ parent.removeChild(el)
97
94
 
98
- // Cache of currently rendered items: Map<key, { node, effects, context }>
99
- let cache = new Map()
100
- let previousOrder = []
95
+ // Cache of currently rendered items: Map<key, { node, effects, context }>
96
+ let cache = new Map()
97
+ let previousOrder = []
101
98
 
102
- effects.push(effect(() => {
103
- const rawItems = evaluateExpression(arrayExpr, context)
104
- let items = []
105
-
106
- if (Array.isArray(rawItems)) {
107
- items = rawItems.map((item, index) => ({ value: item, rawIndex: index }))
108
- } else if (typeof rawItems === 'number' && !isNaN(rawItems)) {
109
- items = Array.from({ length: Math.max(0, Math.floor(rawItems)) }, (_, i) => ({ value: i + 1, rawIndex: i }))
110
- } else if (rawItems && typeof rawItems === 'object') {
111
- items = Object.entries(rawItems).map(([key, value], index) => ({ value, key, rawIndex: index }))
112
- }
99
+ effects.push(effect(() => {
100
+ const rawItems = evaluateExpression(arrayExpr, context)
101
+ let items = []
113
102
 
114
- const nextCache = new Map()
115
- const nextOrder = []
103
+ if (Array.isArray(rawItems)) {
104
+ items = rawItems.map((item, index) => ({ value: item, rawIndex: index }))
105
+ } else if (typeof rawItems === 'number' && !isNaN(rawItems)) {
106
+ items = Array.from({ length: Math.max(0, Math.floor(rawItems)) }, (_, i) => ({ value: i + 1, rawIndex: i }))
107
+ } else if (rawItems && typeof rawItems === 'object') {
108
+ items = Object.entries(rawItems).map(([key, value], index) => ({ value, key, rawIndex: index }))
109
+ }
116
110
 
117
- const newItemsWithKeys = items.map((item, index) => {
118
- const itemContext = Object.create(context)
119
- itemContext[itemName] = item.value
120
- if (keyName) itemContext[keyName] = item.hasOwnProperty('key') ? item.key : item.rawIndex
121
- if (indexName) itemContext[indexName] = index
111
+ const nextCache = new Map()
112
+ const nextOrder = []
122
113
 
123
- const key = keyExpr ? evaluateExpression(keyExpr, itemContext) : item.rawIndex
124
- return { item, key, itemContext }
125
- })
114
+ const newItemsWithKeys = items.map((item, index) => {
115
+ const itemContext = Object.create(context)
116
+ itemContext[itemName] = item.value
117
+ if (keyName) itemContext[keyName] = item.hasOwnProperty('key') ? item.key : item.rawIndex
118
+ if (indexName) itemContext[indexName] = index
126
119
 
127
- const parentNode = anchor.parentNode
128
- let currentCursor = anchor.nextSibling
120
+ const key = keyExpr ? evaluateExpression(keyExpr, itemContext) : item.rawIndex
121
+ return { item, key }
122
+ })
129
123
 
130
- newItemsWithKeys.forEach(({ item, key, itemContext }, index) => {
131
- let entry = cache.get(key)
124
+ const parentNode = anchor.parentNode
125
+ let currentCursor = anchor.nextSibling
132
126
 
133
- if (entry) {
134
- entry.context[itemName] = item.value
135
- if (keyName) entry.context[keyName] = item.hasOwnProperty('key') ? item.key : item.rawIndex
136
- if (indexName) entry.context[indexName] = index
127
+ newItemsWithKeys.forEach(({ item, key }, index) => {
128
+ let entry = cache.get(key)
137
129
 
138
- if (entry.node !== currentCursor) {
139
- parentNode.insertBefore(entry.node, currentCursor)
140
- } else {
141
- currentCursor = currentCursor.nextSibling
130
+ if (entry) {
131
+ // Update existing reactive signals for reused items
132
+ batch(() => {
133
+ if (typeof entry.context[itemName] === 'function' && entry.context[itemName].isSignal) {
134
+ entry.context[itemName](item.value)
142
135
  }
143
- cache.delete(key)
144
- } else {
145
- // Create new node
146
- const clone = templateNode.cloneNode(true)
147
- const childEffects = []
148
- compileNode(clone, itemContext, components, childEffects)
136
+ if (keyName && typeof entry.context[keyName] === 'function' && entry.context[keyName].isSignal) {
137
+ entry.context[keyName](item.hasOwnProperty('key') ? item.key : item.rawIndex)
138
+ }
139
+ if (indexName && typeof entry.context[indexName] === 'function' && entry.context[indexName].isSignal) {
140
+ entry.context[indexName](index)
141
+ }
142
+ })
149
143
 
150
- parentNode.insertBefore(clone, currentCursor)
151
- entry = { node: clone, effects: childEffects, context: itemContext }
144
+ if (entry.node !== currentCursor) {
145
+ parentNode.insertBefore(entry.node, currentCursor)
146
+ } else {
147
+ currentCursor = currentCursor.nextSibling
152
148
  }
149
+ cache.delete(key)
150
+ } else {
151
+ // Create new item context with signals for local state
152
+ const itemContext = Object.create(context)
153
+ itemContext[itemName] = signal(item.value)
154
+ if (keyName) itemContext[keyName] = signal(item.hasOwnProperty('key') ? item.key : item.rawIndex)
155
+ if (indexName) itemContext[indexName] = signal(index)
153
156
 
154
- nextCache.set(key, entry)
155
- nextOrder.push(entry.node)
156
- })
157
+ const clone = templateNode.cloneNode(true)
158
+ const childEffects = []
159
+ compileNode(clone, itemContext, components, childEffects)
157
160
 
158
- for (const [key, entry] of cache.entries()) {
159
- for (const e of entry.effects) if (typeof e === 'function') e()
160
- entry.node.parentNode?.removeChild(entry.node)
161
+ parentNode.insertBefore(clone, currentCursor)
162
+ entry = { node: clone, effects: childEffects, context: itemContext }
161
163
  }
162
164
 
163
- cache = nextCache
164
- previousOrder = nextOrder
165
- }))
166
- }
167
- }
165
+ nextCache.set(key, entry)
166
+ nextOrder.push(entry.node)
167
+ })
168
168
 
169
- export function processIf(root, context, components, effects) {
170
- const ifEls = Array.from(root.querySelectorAll('[f-if]'))
171
- const processedEls = new Set()
169
+ // Cleanup removed nodes
170
+ for (const [_, entry] of cache.entries()) {
171
+ for (const e of entry.effects) if (typeof e === 'function') e()
172
+ entry.node.parentNode?.removeChild(entry.node)
173
+ }
172
174
 
173
- for (const ifEl of ifEls) {
174
- if (processedEls.has(ifEl)) continue
175
+ cache = nextCache
176
+ previousOrder = nextOrder
177
+ }))
175
178
 
176
- const parent = ifEl.parentNode
177
- if (!parent) continue
179
+ effects.push(() => {
180
+ for (const entry of cache.values()) {
181
+ for (const cleanup of entry.effects) if (typeof cleanup === 'function') cleanup()
182
+ }
183
+ cache.clear()
184
+ })
178
185
 
179
- const group = []
180
- let curr = ifEl
186
+ return true
187
+ }
181
188
 
182
- while (curr) {
183
- if (curr.nodeType === 1) {
184
- const type = curr.hasAttribute('f-if') ? 'if' :
185
- curr.hasAttribute('f-elif') ? 'elif' :
186
- curr.hasAttribute('f-else-if') ? 'elif' :
187
- curr.hasAttribute('f-else') ? 'else' : null
189
+ export function processIf(el, context, components, effects) {
190
+ if (el.nodeType !== 1 || !el.hasAttribute('f-if')) return false
188
191
 
189
- if (type) {
190
- const expr = type === 'else' ? 'true' : curr.getAttribute(`f-${type === 'elif' && curr.hasAttribute('f-else-if') ? 'else-if' : type}`)
191
- group.push({ node: curr, expr, type, isElement: true })
192
- processedEls.add(curr)
192
+ const parent = el.parentNode
193
+ if (!parent) return false
193
194
 
194
- curr.removeAttribute('f-if')
195
- curr.removeAttribute('f-elif')
196
- curr.removeAttribute('f-else-if')
197
- curr.removeAttribute('f-else')
195
+ const group = []
196
+ let curr = el
198
197
 
199
- if (type === 'else') break
200
- } else {
201
- break
202
- }
203
- } else if (curr.nodeType === 3 && curr.textContent.trim() === '' || curr.nodeType === 8) {
204
- group.push({ node: curr, isElement: false })
198
+ while (curr) {
199
+ if (curr.nodeType === 1) {
200
+ const type = curr.hasAttribute('f-if') ? 'if' :
201
+ curr.hasAttribute('f-elif') ? 'elif' :
202
+ curr.hasAttribute('f-else-if') ? 'elif' :
203
+ curr.hasAttribute('f-else') ? 'else' : null
204
+
205
+ if (type) {
206
+ const expr = type === 'else' ? 'true' : curr.getAttribute(`f-${type === 'elif' && curr.hasAttribute('f-else-if') ? 'else-if' : type}`)
207
+ group.push({ node: curr, expr, type, isElement: true })
208
+
209
+ curr.removeAttribute('f-if')
210
+ curr.removeAttribute('f-elif')
211
+ curr.removeAttribute('f-else-if')
212
+ curr.removeAttribute('f-else')
213
+
214
+ if (type === 'else') break
205
215
  } else {
206
216
  break
207
217
  }
208
- curr = curr.nextSibling
218
+ } else if (curr.nodeType === 3 && curr.textContent.trim() === '' || curr.nodeType === 8) {
219
+ group.push({ node: curr, isElement: false })
220
+ } else {
221
+ break
209
222
  }
223
+ curr = curr.nextSibling
224
+ }
210
225
 
211
- const templates = group
212
- .filter(item => item.isElement)
213
- .map(item => ({
214
- template: item.node.cloneNode(true),
215
- expr: item.expr,
216
- type: item.type
217
- }))
226
+ const templates = group
227
+ .filter(item => item.isElement)
228
+ .map(item => ({
229
+ template: item.node.cloneNode(true),
230
+ expr: item.expr,
231
+ type: item.type
232
+ }))
218
233
 
219
- const anchor = document.createComment('f-condition-anchor')
220
- parent.insertBefore(anchor, ifEl)
234
+ const anchor = document.createComment('f-condition-anchor')
235
+ parent.insertBefore(anchor, el)
221
236
 
222
- for (const item of group) item.node.remove()
237
+ for (const item of group) item.node.remove()
223
238
 
224
- let activeIndex = -1
225
- let activeNode = null
226
- let activeCleanup = []
239
+ let activeIndex = -1
240
+ let activeNode = null
241
+ let activeCleanup = []
227
242
 
228
- effects.push(effect(() => {
229
- let nextIndex = -1
230
- for (let i = 0; i < templates.length; i++) {
231
- const isMatch = templates[i].type === 'else' ? true : evaluateExpression(templates[i].expr, context)
232
- if (isMatch) {
233
- nextIndex = i
234
- break
235
- }
243
+ effects.push(effect(() => {
244
+ let nextIndex = -1
245
+ for (let i = 0; i < templates.length; i++) {
246
+ const isMatch = templates[i].type === 'else' ? true : evaluateExpression(templates[i].expr, context)
247
+ if (isMatch) {
248
+ nextIndex = i
249
+ break
236
250
  }
251
+ }
237
252
 
238
- if (nextIndex !== activeIndex) {
239
- for (const cleanup of activeCleanup) if (typeof cleanup === 'function') cleanup()
240
- activeCleanup = []
241
- if (activeNode) {
242
- activeNode.remove()
243
- activeNode = null
244
- }
253
+ if (nextIndex !== activeIndex) {
254
+ for (const cleanup of activeCleanup) if (typeof cleanup === 'function') cleanup()
255
+ activeCleanup = []
256
+ if (activeNode) {
257
+ activeNode.remove()
258
+ activeNode = null
259
+ }
245
260
 
246
- if (nextIndex !== -1) {
247
- activeNode = templates[nextIndex].template.cloneNode(true)
248
- compileNode(activeNode, context, components, activeCleanup)
249
- anchor.parentNode.insertBefore(activeNode, anchor.nextSibling)
250
- }
251
- activeIndex = nextIndex
261
+ if (nextIndex !== -1) {
262
+ activeNode = templates[nextIndex].template.cloneNode(true)
263
+ compileNode(activeNode, context, components, activeCleanup)
264
+ anchor.parentNode.insertBefore(activeNode, anchor.nextSibling)
252
265
  }
253
- }))
254
- }
266
+ activeIndex = nextIndex
267
+ }
268
+ }))
269
+
270
+ effects.push(() => {
271
+ for (const cleanup of activeCleanup) if (typeof cleanup === 'function') cleanup()
272
+ activeCleanup = []
273
+ })
274
+
275
+ return true
255
276
  }
256
277
 
257
- export function processShow(root, context, effects) {
258
- const showEls = root.querySelectorAll('[f-show]')
259
- for (const el of showEls) {
260
- if (!el.parentNode) continue
278
+ export function processShow(el, context, effects) {
279
+ if (el.nodeType !== 1 || !el.hasAttribute('f-show')) return
261
280
 
262
- const expr = el.getAttribute('f-show')
263
- el.removeAttribute('f-show')
264
- const originalDisplay = el.style.display === 'none' ? '' : el.style.display
281
+ const expr = el.getAttribute('f-show')
282
+ el.removeAttribute('f-show')
283
+ const originalDisplay = el.style.display === 'none' ? '' : el.style.display
265
284
 
266
- effects.push(effect(() => {
267
- const val = evaluateExpression(expr, context)
268
- el.style.display = val ? originalDisplay : 'none'
269
- }))
270
- }
285
+ effects.push(effect(() => {
286
+ const val = evaluateExpression(expr, context)
287
+ el.style.display = val ? originalDisplay : 'none'
288
+ }))
271
289
  }
272
290
 
273
- export function processHtml(root, context, effects) {
274
- const els = root.querySelectorAll('[f-html]')
275
- for (const el of els) {
276
- if (!el.parentNode) continue
277
-
278
- const expr = el.getAttribute('f-html')
279
- el.removeAttribute('f-html')
291
+ export function processHtml(el, context, effects) {
292
+ if (el.nodeType !== 1 || !el.hasAttribute('f-html')) return
280
293
 
281
- const isDev = typeof import.meta.env !== 'undefined' ? !!import.meta.env.DEV : true
282
- if (isDev) {
283
- console.warn(`[framework] f-html can expose the site to XSS. Use only with trusted content.`)
284
- }
294
+ const expr = el.getAttribute('f-html')
295
+ el.removeAttribute('f-html')
285
296
 
286
- effects.push(effect(() => {
287
- const val = evaluateExpression(expr, context)
288
- el.innerHTML = String(val ?? '')
289
- }))
297
+ const isDev = typeof import.meta.env !== 'undefined' ? !!import.meta.env.DEV : true
298
+ if (isDev) {
299
+ console.warn(`[framework] f-html can expose the site to XSS. Use only with trusted content.`)
290
300
  }
301
+
302
+ effects.push(effect(() => {
303
+ const val = evaluateExpression(expr, context)
304
+ el.innerHTML = String(val ?? '')
305
+ }))
291
306
  }
292
- export function processEvents(root, context) {
293
- const els = root.querySelectorAll('*')
294
- const targets = root.attributes ? [root, ...els] : els
295
-
296
- for (const el of targets) {
297
- if (!el.attributes) continue
298
-
299
- for (const attr of [...el.attributes]) {
300
- if (attr.name.startsWith('@')) {
301
- const fullEventName = attr.name.slice(1)
302
- const parts = fullEventName.split('.')
303
- const eventName = parts[0]
304
- const modifiers = parts.slice(1)
305
- const expr = attr.value
306
-
307
- el.removeAttribute(attr.name)
308
-
309
- const handler = (e) => {
310
- if (modifiers.includes('prevent')) e.preventDefault()
311
- if (modifiers.includes('stop')) e.stopPropagation()
312
- if (modifiers.includes('self') && e.target !== el) return
313
-
314
- // Specific handling for common modifiers like .enter, .escape etc on keyboard events
315
- if (e instanceof KeyboardEvent) {
316
- const key = e.key.toLowerCase()
317
- const keyModifiers = modifiers.filter(m => ['enter', 'escape', 'tab', 'space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(m))
318
- if (keyModifiers.length > 0) {
319
- const match = keyModifiers.some(m => {
320
- if (m === 'escape') return key === 'escape' || key === 'esc'
321
- if (m === 'space') return key === ' ' || key === 'spacebar'
322
- return key === m
323
- })
324
- if (!match) return
325
- }
307
+ export function processEvents(el, context, effects) {
308
+ if (el.nodeType !== 1 || !el.attributes) return
309
+
310
+ for (const attr of [...el.attributes]) {
311
+ if (attr.name.startsWith('@')) {
312
+ const fullEventName = attr.name.slice(1)
313
+ const parts = fullEventName.split('.')
314
+ const eventName = parts[0]
315
+ const modifiers = parts.slice(1)
316
+ const expr = attr.value
317
+
318
+ el.removeAttribute(attr.name)
319
+
320
+ const handler = (e) => {
321
+ if (modifiers.includes('prevent')) e.preventDefault()
322
+ if (modifiers.includes('stop')) e.stopPropagation()
323
+ if (modifiers.includes('self') && e.target !== el) return
324
+
325
+ // Specific handling for common modifiers like .enter, .escape etc on keyboard events
326
+ if (e instanceof KeyboardEvent) {
327
+ const key = e.key.toLowerCase()
328
+ const keyModifiers = modifiers.filter(m => ['enter', 'escape', 'tab', 'space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(m))
329
+ if (keyModifiers.length > 0) {
330
+ const match = keyModifiers.some(m => {
331
+ if (m === 'escape') return key === 'escape' || key === 'esc'
332
+ if (m === 'space') return key === ' ' || key === 'spacebar'
333
+ return key === m
334
+ })
335
+ if (!match) return
326
336
  }
327
-
328
- batch(() => {
329
- const potentialFn = context[expr]
330
- if (typeof potentialFn === 'function' && !potentialFn.isSignal) {
331
- potentialFn(e)
332
- } else {
333
- evaluateExpression(expr, context, { $event: e }, false)
334
- }
335
- })
336
337
  }
337
338
 
338
- const targetNode = modifiers.includes('window') ? window : (modifiers.includes('document') ? document : el)
339
- const options = {
340
- once: modifiers.includes('once'),
341
- capture: modifiers.includes('capture') || modifiers.includes('away')
342
- }
339
+ batch(() => {
340
+ const potentialFn = context[expr]
341
+ if (typeof potentialFn === 'function' && !potentialFn.isSignal) {
342
+ potentialFn(e)
343
+ } else {
344
+ evaluateExpression(expr, context, { $event: e }, false)
345
+ }
346
+ })
347
+ }
343
348
 
344
- targetNode.addEventListener(eventName, handler, options)
349
+ const targetNode = modifiers.includes('window') ? window : (modifiers.includes('document') ? document : el)
350
+ const options = {
351
+ once: modifiers.includes('once'),
352
+ capture: modifiers.includes('capture') || modifiers.includes('away')
353
+ }
354
+
355
+ targetNode.addEventListener(eventName, handler, options)
356
+ if (effects) {
357
+ effects.push(() => {
358
+ targetNode.removeEventListener(eventName, handler, options)
359
+ })
345
360
  }
346
361
  }
347
362
  }
348
363
  }
349
364
 
350
- export function processText(root, context, effects) {
351
- const els = root.querySelectorAll('[f-text]')
352
- for (const el of els) {
353
- if (!el.parentNode) continue
354
- const expr = el.getAttribute('f-text')
355
- el.removeAttribute('f-text')
356
- effects.push(effect(() => {
357
- const val = evaluateExpression(expr, context)
358
- el.textContent = String(val ?? '')
359
- }))
360
- }
365
+ export function processText(el, context, effects) {
366
+ if (el.nodeType !== 1 || !el.hasAttribute('f-text')) return
367
+ const expr = el.getAttribute('f-text')
368
+ el.removeAttribute('f-text')
369
+ effects.push(effect(() => {
370
+ const val = evaluateExpression(expr, context)
371
+ el.textContent = String(val ?? '')
372
+ }))
361
373
  }
362
374
 
363
- export function processCloak(root) {
364
- const els = root.querySelectorAll('[f-cloak]')
365
- const targets = root.hasAttribute && root.hasAttribute('f-cloak') ? [root, ...els] : els
366
- for (const el of targets) {
367
- el.removeAttribute('f-cloak')
368
- }
375
+ export function processCloak(el) {
376
+ if (el.nodeType !== 1) return
377
+ if (el.hasAttribute('f-cloak')) el.removeAttribute('f-cloak')
378
+
379
+ const els = el.querySelectorAll('[f-cloak]')
380
+ for (const e of els) e.removeAttribute('f-cloak')
369
381
  }