micra.js 1.0.0 → 2.0.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.
- package/README.md +7 -4
- package/dist/dom/events.d.ts +9 -4
- package/dist/dom/query.d.ts +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/micra.cjs.js +219 -53
- package/dist/micra.cjs.js.map +3 -3
- package/dist/micra.esm.js +219 -53
- package/dist/micra.esm.js.map +3 -3
- package/dist/micra.js +219 -53
- package/dist/micra.js.map +3 -3
- package/dist/micra.min.js +2 -2
- package/dist/types.d.ts +33 -4
- package/dist/utils/expr.d.ts +12 -1
- package/package.json +2 -2
- package/src/core/bus.ts +4 -1
- package/src/core/mount.ts +54 -3
- package/src/dom/directives.ts +107 -29
- package/src/dom/each.ts +14 -2
- package/src/dom/events.ts +50 -20
- package/src/dom/query.ts +15 -1
- package/src/index.ts +1 -1
- package/src/types.ts +36 -4
- package/src/utils/expr.ts +119 -7
- package/src/utils/fetch.ts +2 -2
package/src/core/mount.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
ComponentInstance,
|
|
18
18
|
EventHandler,
|
|
19
19
|
InternalInstance,
|
|
20
|
+
MicraElement,
|
|
20
21
|
StateRecord,
|
|
21
22
|
UnsubFn,
|
|
22
23
|
} from '../types'
|
|
@@ -97,16 +98,46 @@ export function mount<S extends StateRecord>(
|
|
|
97
98
|
|
|
98
99
|
// Expression state: proxy that falls back to instance methods so expressions
|
|
99
100
|
// like `data-text="formatDate(item.date)"` can call component methods.
|
|
101
|
+
//
|
|
102
|
+
// Instance methods are returned BOUND to the instance — directive expressions
|
|
103
|
+
// call them as bare identifiers via `with()`, which would normally lose `this`.
|
|
104
|
+
// Bound copies are memoized per method name so repeated reads are cheap.
|
|
105
|
+
//
|
|
106
|
+
// Both traps reject Object.prototype names ('constructor', 'toString', ...) —
|
|
107
|
+
// accessing them via a directive expression returns undefined instead of
|
|
108
|
+
// leaking the prototype.
|
|
109
|
+
const boundMethods = new Map<string, Function>()
|
|
100
110
|
const exprState = new Proxy(rawState, {
|
|
101
111
|
get(target, key: string) {
|
|
102
|
-
if (key
|
|
103
|
-
if (key
|
|
112
|
+
if (Object.prototype.hasOwnProperty.call(target, key)) return target[key]
|
|
113
|
+
if (Object.prototype.hasOwnProperty.call(instance, key) &&
|
|
114
|
+
typeof instance[key] === 'function') {
|
|
115
|
+
const cached = boundMethods.get(key)
|
|
116
|
+
if (cached) return cached
|
|
117
|
+
const bound = (instance[key] as Function).bind(instance)
|
|
118
|
+
boundMethods.set(key, bound)
|
|
119
|
+
return bound
|
|
120
|
+
}
|
|
104
121
|
return undefined
|
|
105
122
|
},
|
|
123
|
+
has(target, key: string) {
|
|
124
|
+
if (typeof key !== 'string') return false
|
|
125
|
+
if (Object.prototype.hasOwnProperty.call(target, key)) return true
|
|
126
|
+
return Object.prototype.hasOwnProperty.call(instance, key) &&
|
|
127
|
+
typeof instance[key] === 'function'
|
|
128
|
+
},
|
|
106
129
|
})
|
|
107
130
|
|
|
131
|
+
let warnedReentry = false
|
|
108
132
|
instance.render = function () {
|
|
109
|
-
if (
|
|
133
|
+
if (instance.__micraDestroyed) return
|
|
134
|
+
if (isRendering) {
|
|
135
|
+
if (!warnedReentry) {
|
|
136
|
+
warn('render() re-entry detected — mutation inside a directive expression is ignored. Move state writes to a method.')
|
|
137
|
+
warnedReentry = true
|
|
138
|
+
}
|
|
139
|
+
return
|
|
140
|
+
}
|
|
110
141
|
isRendering = true
|
|
111
142
|
try {
|
|
112
143
|
applyDirectives(root, exprState, rawState, instance)
|
|
@@ -122,7 +153,27 @@ export function mount<S extends StateRecord>(
|
|
|
122
153
|
|
|
123
154
|
// ── Destroy ───────────────────────────────────────────────────────────────
|
|
124
155
|
instance.destroy = function () {
|
|
156
|
+
if (instance.__micraDestroyed) return
|
|
157
|
+
instance.__micraDestroyed = true
|
|
158
|
+
|
|
159
|
+
// Remove every DOM listener attached by bindDataOn / bindAtEvents / bindModels.
|
|
160
|
+
instance.__micraListeners?.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
|
|
161
|
+
instance.__micraListeners = []
|
|
162
|
+
|
|
163
|
+
// Clear per-element flags & cached directive scan so a future re-mount of the same DOM works.
|
|
164
|
+
const clearFlags = (el: Element) => {
|
|
165
|
+
const m = el as MicraElement
|
|
166
|
+
delete m.__micraEvents
|
|
167
|
+
delete m.__micraAtBound
|
|
168
|
+
delete m.__micraModel
|
|
169
|
+
delete m.__micraCache
|
|
170
|
+
}
|
|
171
|
+
clearFlags(root)
|
|
172
|
+
root.querySelectorAll('*').forEach(clearFlags)
|
|
173
|
+
|
|
125
174
|
instance.__micraSubs?.forEach(unsub => unsub())
|
|
175
|
+
instance.__micraSubs = []
|
|
176
|
+
|
|
126
177
|
if (typeof (definition as Record<string, unknown>).onDestroy === 'function')
|
|
127
178
|
(definition.onDestroy as () => void).call(instance)
|
|
128
179
|
_instances.delete(root)
|
package/src/dom/directives.ts
CHANGED
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
|
|
16
16
|
import type {
|
|
17
17
|
CachedBinding,
|
|
18
|
+
CachedIfBinding,
|
|
19
|
+
CachedPairBinding,
|
|
18
20
|
DirectiveCache,
|
|
19
21
|
InternalInstance,
|
|
20
22
|
MicraElement,
|
|
23
|
+
MicraTemplate,
|
|
21
24
|
StateRecord,
|
|
22
25
|
} from '../types'
|
|
23
26
|
import { evalExpr, warn } from '../utils/expr'
|
|
@@ -31,21 +34,60 @@ function applyText(el: Element, expr: string, state: StateRecord): void {
|
|
|
31
34
|
if (el.textContent !== text) el.textContent = text
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* data-html — writes the expression value as innerHTML.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ XSS WARNING: the value is rendered as raw HTML. Never bind untrusted
|
|
41
|
+
* input here — use `data-text` (textContent) instead. See docs/directives.md
|
|
42
|
+
* for the full security model.
|
|
43
|
+
*/
|
|
34
44
|
function applyHtml(el: Element, expr: string, state: StateRecord): void {
|
|
35
45
|
el.innerHTML = String(evalExpr(expr, state) ?? '')
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
|
|
48
|
+
/**
|
|
49
|
+
* data-if — true mount/unmount. When the expression is falsy, the element is
|
|
50
|
+
* detached from the DOM and a Comment placeholder takes its slot. When truthy,
|
|
51
|
+
* the element is re-inserted where the placeholder is.
|
|
52
|
+
*
|
|
53
|
+
* Side effect: when an element is detached, its `data-ref` is gone from
|
|
54
|
+
* `this.refs` and its `data-model` listener still exists on the (detached)
|
|
55
|
+
* node — listeners survive detach.
|
|
56
|
+
*
|
|
57
|
+
* Use `data-show` when you want the cheap display:none toggle instead.
|
|
58
|
+
*/
|
|
59
|
+
function applyIf(binding: CachedIfBinding, state: StateRecord): void {
|
|
60
|
+
const el = binding.el as HTMLElement
|
|
61
|
+
const truthy = !!evalExpr(binding.expr, state)
|
|
62
|
+
if (truthy) {
|
|
63
|
+
// If a placeholder is currently in the DOM in the element's slot, swap back.
|
|
64
|
+
const ph = binding.placeholder
|
|
65
|
+
if (ph && ph.parentNode) ph.parentNode.replaceChild(el, ph)
|
|
66
|
+
} else {
|
|
67
|
+
// Only detach if currently attached somewhere. Standalone elements
|
|
68
|
+
// (no parent — common in unit tests) are a no-op.
|
|
69
|
+
const parent = el.parentNode
|
|
70
|
+
if (parent) {
|
|
71
|
+
if (!binding.placeholder) binding.placeholder = document.createComment('if')
|
|
72
|
+
parent.replaceChild(binding.placeholder, el)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* data-show — visibility toggle via `style.display`. Element stays in the DOM.
|
|
79
|
+
*/
|
|
80
|
+
function applyShow(el: Element, expr: string, state: StateRecord): void {
|
|
39
81
|
(el as HTMLElement).style.display = evalExpr(expr, state) ? '' : 'none'
|
|
40
82
|
}
|
|
41
83
|
|
|
42
|
-
function applyBind(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const val
|
|
84
|
+
function applyBind(
|
|
85
|
+
el: Element,
|
|
86
|
+
pairs: ReadonlyArray<readonly [string, string]>,
|
|
87
|
+
state: StateRecord,
|
|
88
|
+
): void {
|
|
89
|
+
for (const [attr, valExpr] of pairs) {
|
|
90
|
+
const val = evalExpr(valExpr, state)
|
|
49
91
|
|
|
50
92
|
if (attr === 'class') {
|
|
51
93
|
(el as HTMLElement).className = String(val ?? '')
|
|
@@ -76,26 +118,42 @@ function applyBind(el: Element, expr: string, state: StateRecord): void {
|
|
|
76
118
|
* @example
|
|
77
119
|
* <div data-class="active:tab === 'home', hidden:!loaded">
|
|
78
120
|
*/
|
|
79
|
-
function applyClass(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (!cls) continue
|
|
121
|
+
function applyClass(
|
|
122
|
+
el: Element,
|
|
123
|
+
pairs: ReadonlyArray<readonly [string, string]>,
|
|
124
|
+
state: StateRecord,
|
|
125
|
+
): void {
|
|
126
|
+
for (const [cls, valExpr] of pairs) {
|
|
86
127
|
el.classList.toggle(cls, Boolean(evalExpr(valExpr, state)))
|
|
87
128
|
}
|
|
88
129
|
}
|
|
89
130
|
|
|
131
|
+
/** @internal Parse a comma+colon spec like `href:url, disabled:loading` once. */
|
|
132
|
+
function parsePairs(expr: string): Array<readonly [string, string]> {
|
|
133
|
+
const out: Array<readonly [string, string]> = []
|
|
134
|
+
for (const part of expr.split(',')) {
|
|
135
|
+
const colonIdx = part.indexOf(':')
|
|
136
|
+
if (colonIdx === -1) continue
|
|
137
|
+
const left = part.slice(0, colonIdx).trim()
|
|
138
|
+
const right = part.slice(colonIdx + 1).trim()
|
|
139
|
+
if (!left) continue
|
|
140
|
+
out.push([left, right])
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
90
145
|
function applyModel(
|
|
91
146
|
el: Element,
|
|
92
147
|
key: string,
|
|
93
148
|
rawState: StateRecord,
|
|
94
149
|
): void {
|
|
95
150
|
const html = el as HTMLInputElement
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
151
|
+
const stateVal = rawState[key]
|
|
152
|
+
const desired = stateVal == null ? '' : String(stateVal)
|
|
153
|
+
// Only write when out of sync. This is a no-op during live typing (the input
|
|
154
|
+
// event already drove state to match el.value) but still propagates
|
|
155
|
+
// programmatic resets such as `this.state.q = ''` on focused inputs.
|
|
156
|
+
if (html.value !== desired) html.value = desired
|
|
99
157
|
// listener is attached separately in events.ts — this only syncs the value
|
|
100
158
|
}
|
|
101
159
|
|
|
@@ -111,14 +169,16 @@ function buildCache(root: Element): DirectiveCache {
|
|
|
111
169
|
.filter(el => !el.closest('template'))
|
|
112
170
|
.map(el => ({ el, expr: el.getAttribute(attr)! }))
|
|
113
171
|
}
|
|
172
|
+
const pickPairs = (attr: string): CachedPairBinding[] =>
|
|
173
|
+
pick(attr).map(b => ({ ...b, pairs: parsePairs(b.expr) }))
|
|
114
174
|
return {
|
|
115
175
|
text: pick('data-text'),
|
|
116
176
|
html: pick('data-html'),
|
|
117
|
-
if: pick('data-if'),
|
|
177
|
+
if: pick('data-if') as CachedIfBinding[],
|
|
118
178
|
show: pick('data-show'),
|
|
119
|
-
bind:
|
|
179
|
+
bind: pickPairs('data-bind'),
|
|
120
180
|
model: pick('data-model'),
|
|
121
|
-
class:
|
|
181
|
+
class: pickPairs('data-class'),
|
|
122
182
|
}
|
|
123
183
|
}
|
|
124
184
|
|
|
@@ -161,13 +221,15 @@ function applyFromList(
|
|
|
161
221
|
state: StateRecord,
|
|
162
222
|
rawState: StateRecord,
|
|
163
223
|
): void {
|
|
224
|
+
// data-if runs first so subsequent directives don't write into a tree that's
|
|
225
|
+
// about to be detached this tick.
|
|
226
|
+
cache.if.forEach(b => applyIf(b, state))
|
|
164
227
|
cache.text.forEach(b => applyText(b.el, b.expr, state))
|
|
165
228
|
cache.html.forEach(b => applyHtml(b.el, b.expr, state))
|
|
166
|
-
cache.
|
|
167
|
-
cache.
|
|
168
|
-
cache.bind.forEach(b => applyBind(b.el, b.expr, state))
|
|
229
|
+
cache.show.forEach(b => applyShow(b.el, b.expr, state))
|
|
230
|
+
cache.bind.forEach(b => applyBind(b.el, b.pairs, state))
|
|
169
231
|
cache.model.forEach(b => applyModel(b.el, b.expr.trim(), rawState))
|
|
170
|
-
cache.class.forEach(b => applyClass(b.el, b.
|
|
232
|
+
cache.class.forEach(b => applyClass(b.el, b.pairs, state))
|
|
171
233
|
}
|
|
172
234
|
|
|
173
235
|
/** @internal Scan a DocumentFragment (no-key each clone) — returns a DirectiveCache. */
|
|
@@ -176,14 +238,16 @@ function buildFragmentList(frag: DocumentFragment): DirectiveCache {
|
|
|
176
238
|
queryAll(frag, `[${attr}]`)
|
|
177
239
|
.filter(el => !el.closest('template'))
|
|
178
240
|
.map(el => ({ el, expr: el.getAttribute(attr)! }))
|
|
241
|
+
const pickPairs = (attr: string): CachedPairBinding[] =>
|
|
242
|
+
pick(attr).map(b => ({ ...b, pairs: parsePairs(b.expr) }))
|
|
179
243
|
return {
|
|
180
244
|
text: pick('data-text'),
|
|
181
245
|
html: pick('data-html'),
|
|
182
|
-
if: pick('data-if'),
|
|
246
|
+
if: pick('data-if') as CachedIfBinding[],
|
|
183
247
|
show: pick('data-show'),
|
|
184
|
-
bind:
|
|
248
|
+
bind: pickPairs('data-bind'),
|
|
185
249
|
model: pick('data-model'),
|
|
186
|
-
class:
|
|
250
|
+
class: pickPairs('data-class'),
|
|
187
251
|
}
|
|
188
252
|
}
|
|
189
253
|
|
|
@@ -197,10 +261,24 @@ function buildFragmentList(frag: DocumentFragment): DirectiveCache {
|
|
|
197
261
|
*/
|
|
198
262
|
export function validateDirectives(root: Element): void {
|
|
199
263
|
queryOwn(root, 'data-each').forEach(el => {
|
|
200
|
-
|
|
264
|
+
const tmpl = el as MicraTemplate
|
|
265
|
+
if (!el.hasAttribute('data-key') && !tmpl.__micraNoKeyWarned) {
|
|
266
|
+
tmpl.__micraNoKeyWarned = true
|
|
201
267
|
warn(`data-each="${el.getAttribute('data-each')}" has no data-key — keyed diff disabled. Add data-key="id" for better performance.`)
|
|
202
268
|
}
|
|
203
269
|
})
|
|
270
|
+
|
|
271
|
+
// data-bind="class:..." replaces className wholesale, which fights with
|
|
272
|
+
// data-class on the same element. Warn so the developer picks one.
|
|
273
|
+
const bindEls = queryOwn(root, 'data-bind')
|
|
274
|
+
if ((root as HTMLElement).hasAttribute?.('data-bind') && !bindEls.includes(root)) bindEls.unshift(root)
|
|
275
|
+
for (const el of bindEls) {
|
|
276
|
+
const spec = el.getAttribute('data-bind') ?? ''
|
|
277
|
+
const hasClassBind = spec.split(',').some(p => p.trim().split(':')[0]?.trim() === 'class')
|
|
278
|
+
if (hasClassBind && el.hasAttribute('data-class')) {
|
|
279
|
+
warn(`element has both data-bind="class:..." and data-class — they fight on every render. Use one.`)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
204
282
|
}
|
|
205
283
|
|
|
206
284
|
// Re-export warn for use in other modules
|
package/src/dom/each.ts
CHANGED
|
@@ -53,7 +53,10 @@ export function renderList<S extends StateRecord>(
|
|
|
53
53
|
|
|
54
54
|
const marker = tmpl.__micraMarker
|
|
55
55
|
const keyMap = tmpl.__micraNodes
|
|
56
|
-
const parent = marker.parentNode
|
|
56
|
+
const parent = marker.parentNode
|
|
57
|
+
// The template (and its marker) is currently detached — likely a data-if
|
|
58
|
+
// ancestor unmounted this subtree. Nothing to do until it returns.
|
|
59
|
+
if (!parent) return
|
|
57
60
|
|
|
58
61
|
// Empty / non-array: clear all rendered rows
|
|
59
62
|
if (!Array.isArray(items)) {
|
|
@@ -86,10 +89,19 @@ function renderKeyed<S extends StateRecord>(
|
|
|
86
89
|
): void {
|
|
87
90
|
const nextKeys = new Set<unknown>()
|
|
88
91
|
const nextNodes: MicraElement[] = []
|
|
92
|
+
let warnedNullKey = false
|
|
93
|
+
let warnedDupKey = false
|
|
89
94
|
|
|
90
95
|
for (const [index, item] of items.entries()) {
|
|
91
96
|
const key = item[keyAttr]
|
|
92
|
-
if (key == null
|
|
97
|
+
if (key == null && !warnedNullKey) {
|
|
98
|
+
warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`)
|
|
99
|
+
warnedNullKey = true
|
|
100
|
+
}
|
|
101
|
+
if (nextKeys.has(key) && !warnedDupKey) {
|
|
102
|
+
warn(`data-key="${keyAttr}" has duplicate value ${JSON.stringify(key)} — rows will collide`)
|
|
103
|
+
warnedDupKey = true
|
|
104
|
+
}
|
|
93
105
|
nextKeys.add(key)
|
|
94
106
|
|
|
95
107
|
let node = keyMap.get(key) as MicraElement | undefined
|
package/src/dom/events.ts
CHANGED
|
@@ -3,15 +3,28 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Responsibilities:
|
|
5
5
|
* - Bind `data-on="event:method"` listeners (once per element)
|
|
6
|
-
* - Bind `@event="method"` shorthand (
|
|
6
|
+
* - Bind `@event="method"` shorthand (once per element)
|
|
7
|
+
* - Bind `data-model` two-way input listeners (once per element)
|
|
7
8
|
*
|
|
8
|
-
* LLM NOTE:
|
|
9
|
-
*
|
|
9
|
+
* LLM NOTE: Every listener attached here is also recorded in
|
|
10
|
+
* instance.__micraListeners so destroy() can remove it cleanly.
|
|
11
|
+
* Re-render skips already-bound elements via per-element __micra* flags.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import type { InternalInstance, MicraElement, StateRecord } from '../types'
|
|
13
|
-
import {
|
|
14
|
-
import { queryOwn, queryAll } from './query'
|
|
15
|
+
import { warn } from '../utils/expr'
|
|
16
|
+
import { queryOwn, queryOwnAll, queryAll } from './query'
|
|
17
|
+
|
|
18
|
+
/** @internal Attach a DOM listener and track it on the instance for destroy(). */
|
|
19
|
+
function track<S extends StateRecord>(
|
|
20
|
+
instance: InternalInstance<S>,
|
|
21
|
+
el: Element,
|
|
22
|
+
type: string,
|
|
23
|
+
fn: EventListener,
|
|
24
|
+
): void {
|
|
25
|
+
el.addEventListener(type, fn)
|
|
26
|
+
;(instance.__micraListeners ??= []).push({ el, type, fn })
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
// ── data-on ───────────────────────────────────────────────────────────────────
|
|
17
30
|
|
|
@@ -50,7 +63,7 @@ export function bindDataOn<S extends StateRecord>(
|
|
|
50
63
|
|
|
51
64
|
const [evName, ...mods] = evSpec.split('.')
|
|
52
65
|
|
|
53
|
-
el
|
|
66
|
+
track(instance, el, evName!, (e: Event) => {
|
|
54
67
|
if (mods.includes('prevent')) e.preventDefault()
|
|
55
68
|
if (mods.includes('stop')) e.stopPropagation()
|
|
56
69
|
if (mods.includes('self') && e.target !== el) return
|
|
@@ -67,7 +80,7 @@ export function bindDataOn<S extends StateRecord>(
|
|
|
67
80
|
|
|
68
81
|
/**
|
|
69
82
|
* Bind `@event="method"` shorthand attributes (Stimulus-style).
|
|
70
|
-
*
|
|
83
|
+
* Bound once per element via `__micraAtBound` — re-renders are no-ops.
|
|
71
84
|
* Supports the same modifiers as data-on: `@click.prevent="submit"`.
|
|
72
85
|
*
|
|
73
86
|
* @example
|
|
@@ -78,18 +91,25 @@ export function bindAtEvents<S extends StateRecord>(
|
|
|
78
91
|
root: Element,
|
|
79
92
|
instance: InternalInstance<S>,
|
|
80
93
|
): void {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
94
|
+
const isFragment = root.nodeType === 11
|
|
95
|
+
const all = isFragment
|
|
96
|
+
? queryAll(root as unknown as ParentNode, '*')
|
|
97
|
+
: queryOwnAll(root, '*')
|
|
98
|
+
|
|
99
|
+
// Include root itself for the regular-element case
|
|
100
|
+
if (!isFragment && !all.includes(root)) all.unshift(root)
|
|
84
101
|
|
|
85
|
-
const all = queryAll(root, '*')
|
|
86
102
|
for (const el of all) {
|
|
103
|
+
const mEl = el as MicraElement
|
|
104
|
+
if (mEl.__micraAtBound) continue
|
|
105
|
+
|
|
106
|
+
let bound = false
|
|
87
107
|
for (const attr of Array.from(el.attributes)) {
|
|
88
108
|
if (!attr.name.startsWith('@')) continue
|
|
89
109
|
const [evSpec, ...rest] = attr.name.slice(1).split('.')
|
|
90
110
|
const method = attr.value.trim()
|
|
91
111
|
|
|
92
|
-
el
|
|
112
|
+
track(instance, el, evSpec!, (e: Event) => {
|
|
93
113
|
if (rest.includes('prevent')) e.preventDefault()
|
|
94
114
|
if (rest.includes('stop')) e.stopPropagation()
|
|
95
115
|
if (rest.includes('self') && e.target !== el) return
|
|
@@ -98,7 +118,9 @@ export function bindAtEvents<S extends StateRecord>(
|
|
|
98
118
|
if (typeof fn === 'function') (fn as (e: Event) => void).call(instance, e)
|
|
99
119
|
else warn(`method "${method}" not found`)
|
|
100
120
|
})
|
|
121
|
+
bound = true
|
|
101
122
|
}
|
|
123
|
+
if (bound) mEl.__micraAtBound = true
|
|
102
124
|
}
|
|
103
125
|
}
|
|
104
126
|
|
|
@@ -108,6 +130,9 @@ export function bindAtEvents<S extends StateRecord>(
|
|
|
108
130
|
* Two-way binding: `data-model="key"` wires <input>/<select>/<textarea>
|
|
109
131
|
* to `state[key]`. Binding is attached once per element.
|
|
110
132
|
*
|
|
133
|
+
* Numeric inputs (`type="number"` / `type="range"`) write numbers, not strings.
|
|
134
|
+
* Checkbox inputs write booleans. Everything else writes strings.
|
|
135
|
+
*
|
|
111
136
|
* @example
|
|
112
137
|
* <input data-model="search"> // updates state.search on every keystroke
|
|
113
138
|
* <select data-model="sortBy"> // updates state.sortBy on change
|
|
@@ -128,18 +153,23 @@ export function bindModels<S extends StateRecord>(
|
|
|
128
153
|
|
|
129
154
|
const key = (el as HTMLInputElement).dataset['model'] ?? ''
|
|
130
155
|
const tag = el.tagName
|
|
156
|
+
const inputEl = el as HTMLInputElement
|
|
157
|
+
const inputType = inputEl.type
|
|
131
158
|
|
|
132
159
|
const update = () => {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
160
|
+
let val: unknown
|
|
161
|
+
if (tag === 'INPUT' && inputType === 'checkbox') {
|
|
162
|
+
val = inputEl.checked
|
|
163
|
+
} else if (tag === 'INPUT' && (inputType === 'number' || inputType === 'range')) {
|
|
164
|
+
// Empty string → NaN; preserve raw empty as null so state stays "unfilled"
|
|
165
|
+
val = inputEl.value === '' ? null : inputEl.valueAsNumber
|
|
166
|
+
} else {
|
|
167
|
+
val = inputEl.value
|
|
168
|
+
}
|
|
136
169
|
;(instance.state as StateRecord)[key] = val
|
|
137
170
|
}
|
|
138
171
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
: 'input',
|
|
142
|
-
update,
|
|
143
|
-
)
|
|
172
|
+
const evType = tag === 'SELECT' || inputType === 'radio' ? 'change' : 'input'
|
|
173
|
+
track(instance, el, evType, update)
|
|
144
174
|
}
|
|
145
175
|
}
|
package/src/dom/query.ts
CHANGED
|
@@ -25,7 +25,21 @@ export function queryAll(root: ParentNode, sel: string): Element[] {
|
|
|
25
25
|
* owned by that nested component, not by root's component — so we skip it.
|
|
26
26
|
*/
|
|
27
27
|
export function queryOwn(root: Element, attr: string): Element[] {
|
|
28
|
-
return queryAll(root, `[${attr}]`)
|
|
28
|
+
return filterOwn(root, queryAll(root, `[${attr}]`))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Like queryOwn but accepts an arbitrary CSS selector. Used by bindAtEvents
|
|
33
|
+
* which scans `*` for `@`-prefixed attribute names (no attribute selector exists
|
|
34
|
+
* for those).
|
|
35
|
+
*/
|
|
36
|
+
export function queryOwnAll(root: Element, sel: string): Element[] {
|
|
37
|
+
return filterOwn(root, queryAll(root, sel))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @internal Shared subtree-ownership filter. */
|
|
41
|
+
function filterOwn(root: Element, els: Element[]): Element[] {
|
|
42
|
+
return els.filter(el => {
|
|
29
43
|
let node: Element | null = el.parentElement
|
|
30
44
|
while (node && node !== root) {
|
|
31
45
|
if (node.hasAttribute('data-component')) return false
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -105,12 +105,21 @@ export type ComponentDefinition<S extends StateRecord = StateRecord> = {
|
|
|
105
105
|
export interface MicraElement extends HTMLElement {
|
|
106
106
|
__micraModel?: true // data-model listener bound
|
|
107
107
|
__micraEvents?: true // data-on listeners bound
|
|
108
|
-
|
|
108
|
+
__micraAtBound?: true // @event shorthand bound (per-element)
|
|
109
109
|
__micraKey?: unknown // keyed-diff key
|
|
110
110
|
__micraEach?: true // belongs to a no-key each list
|
|
111
111
|
__micraCache?: DirectiveCache // cached directive scan result
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* @internal A DOM listener tracked for cleanup on destroy().
|
|
116
|
+
*/
|
|
117
|
+
export interface TrackedListener {
|
|
118
|
+
el: Element
|
|
119
|
+
type: string
|
|
120
|
+
fn: EventListener
|
|
121
|
+
}
|
|
122
|
+
|
|
114
123
|
/**
|
|
115
124
|
* @internal Extended HTMLTemplateElement with keyed-diff state.
|
|
116
125
|
*/
|
|
@@ -118,6 +127,7 @@ export interface MicraTemplate extends HTMLTemplateElement {
|
|
|
118
127
|
__micraMarker?: Comment
|
|
119
128
|
__micraNodes: Map<unknown, MicraElement>
|
|
120
129
|
__micraList: ChildNode[]
|
|
130
|
+
__micraNoKeyWarned?: true
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
/**
|
|
@@ -128,6 +138,26 @@ export interface CachedBinding {
|
|
|
128
138
|
expr: string
|
|
129
139
|
}
|
|
130
140
|
|
|
141
|
+
/**
|
|
142
|
+
* @internal data-if binding — like CachedBinding but also carries the
|
|
143
|
+
* placeholder Comment that takes the element's slot in the DOM while the
|
|
144
|
+
* element is detached (unmounted).
|
|
145
|
+
*/
|
|
146
|
+
export interface CachedIfBinding extends CachedBinding {
|
|
147
|
+
placeholder?: Comment
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @internal Per-element directive binding with pre-parsed pairs.
|
|
152
|
+
* Used by `data-bind` and `data-class` — both share the
|
|
153
|
+
* `name:expression[, name:expression…]` syntax.
|
|
154
|
+
*/
|
|
155
|
+
export interface CachedPairBinding {
|
|
156
|
+
el: Element
|
|
157
|
+
expr: string
|
|
158
|
+
pairs: ReadonlyArray<readonly [string, string]>
|
|
159
|
+
}
|
|
160
|
+
|
|
131
161
|
/**
|
|
132
162
|
* @internal Directive scan result — built once per Element, reused every render.
|
|
133
163
|
* This is the core of the performance optimization.
|
|
@@ -138,11 +168,11 @@ export interface CachedBinding {
|
|
|
138
168
|
export interface DirectiveCache {
|
|
139
169
|
text: CachedBinding[]
|
|
140
170
|
html: CachedBinding[]
|
|
141
|
-
if:
|
|
171
|
+
if: CachedIfBinding[]
|
|
142
172
|
show: CachedBinding[]
|
|
143
|
-
bind:
|
|
173
|
+
bind: CachedPairBinding[]
|
|
144
174
|
model: CachedBinding[]
|
|
145
|
-
class:
|
|
175
|
+
class: CachedPairBinding[]
|
|
146
176
|
}
|
|
147
177
|
|
|
148
178
|
/**
|
|
@@ -153,5 +183,7 @@ export interface DirectiveCache {
|
|
|
153
183
|
export interface InternalInstance<S extends StateRecord = StateRecord>
|
|
154
184
|
extends ComponentInstance<S> {
|
|
155
185
|
__micraSubs?: UnsubFn[]
|
|
186
|
+
__micraListeners?: TrackedListener[]
|
|
187
|
+
__micraDestroyed?: true
|
|
156
188
|
[key: string]: unknown
|
|
157
189
|
}
|