micra.js 1.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 +166 -0
- package/dist/core/bus.d.ts +32 -0
- package/dist/core/mount.d.ts +27 -0
- package/dist/core/reactive.d.ts +31 -0
- package/dist/core/registry.d.ts +56 -0
- package/dist/core/start.d.ts +26 -0
- package/dist/dom/directives.d.ts +39 -0
- package/dist/dom/each.d.ts +25 -0
- package/dist/dom/events.d.ts +41 -0
- package/dist/dom/query.d.ts +23 -0
- package/dist/dom/refs.d.ts +23 -0
- package/dist/index.d.ts +29 -0
- package/dist/micra.cjs.js +576 -0
- package/dist/micra.cjs.js.map +7 -0
- package/dist/micra.esm.js +554 -0
- package/dist/micra.esm.js.map +7 -0
- package/dist/micra.js +578 -0
- package/dist/micra.js.map +7 -0
- package/dist/micra.min.js +2 -0
- package/dist/types.d.ts +138 -0
- package/dist/utils/expr.d.ts +27 -0
- package/dist/utils/fetch.d.ts +45 -0
- package/package.json +59 -0
- package/src/core/bus.ts +49 -0
- package/src/core/mount.ts +144 -0
- package/src/core/reactive.ts +50 -0
- package/src/core/registry.ts +100 -0
- package/src/core/start.ts +42 -0
- package/src/dom/directives.ts +207 -0
- package/src/dom/each.ts +167 -0
- package/src/dom/events.ts +145 -0
- package/src/dom/query.ts +36 -0
- package/src/dom/refs.ts +35 -0
- package/src/index.ts +42 -0
- package/src/types.ts +157 -0
- package/src/utils/expr.ts +72 -0
- package/src/utils/fetch.ts +100 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/reactive.ts — Reactive state proxy and batch scheduler.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Wrap a plain state object in a Proxy that notifies on writes
|
|
6
|
+
* - Batch multiple synchronous mutations into a single microtask render
|
|
7
|
+
*
|
|
8
|
+
* LLM NOTE: Both functions are PURE constructors — they have no side effects
|
|
9
|
+
* beyond setting up a Proxy / Promise chain. No DOM access here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { StateRecord } from '../types'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wrap `obj` in a shallow Proxy. Any property write calls `schedule()`.
|
|
16
|
+
* Arrays: replace, don't mutate — `state.items = [...state.items, x]`.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const raw = { count: 0 }
|
|
20
|
+
* const state = createReactiveState(raw, render)
|
|
21
|
+
* state.count = 5 // triggers render() in next microtask
|
|
22
|
+
*/
|
|
23
|
+
export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void): S {
|
|
24
|
+
return new Proxy(obj, {
|
|
25
|
+
set(target, key: string, value: unknown) {
|
|
26
|
+
// Cast through StateRecord — TypeScript cannot write through a generic index
|
|
27
|
+
;(target as StateRecord)[key] = value
|
|
28
|
+
schedule()
|
|
29
|
+
return true
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return a debounce function that defers `render` to the next microtask.
|
|
36
|
+
* Multiple calls within the same tick collapse to a single render.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* const schedule = createScheduler(render)
|
|
40
|
+
* schedule() // defers render
|
|
41
|
+
* schedule() // no-op — already pending
|
|
42
|
+
*/
|
|
43
|
+
export function createScheduler(render: () => void): () => void {
|
|
44
|
+
let pending = false
|
|
45
|
+
return function schedule() {
|
|
46
|
+
if (pending) return
|
|
47
|
+
pending = true
|
|
48
|
+
Promise.resolve().then(() => { pending = false; render() })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/registry.ts — Component definition registry and instance store.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Store named component definitions (define / registry)
|
|
6
|
+
* - Store live component instances keyed by root HTMLElement (instances)
|
|
7
|
+
*
|
|
8
|
+
* LLM NOTE: Both maps are module-level singletons (one per page load).
|
|
9
|
+
* They are intentionally mutable from mount.ts and start.ts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
ComponentDefinition,
|
|
14
|
+
ComponentInstance,
|
|
15
|
+
InternalInstance,
|
|
16
|
+
StateRecord,
|
|
17
|
+
} from '../types'
|
|
18
|
+
|
|
19
|
+
// Named definition map — populated by define()
|
|
20
|
+
export const _registry = new Map<string, ComponentDefinition>()
|
|
21
|
+
|
|
22
|
+
// Live instance map — populated by mount(), cleared by instance.destroy()
|
|
23
|
+
export const _instances = new Map<HTMLElement, InternalInstance>()
|
|
24
|
+
|
|
25
|
+
// ── Public accessors ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a component definition under `name`.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* define('counter', { state: { count: 0 }, inc() { this.state.count++ } })
|
|
32
|
+
*/
|
|
33
|
+
export function define<S extends StateRecord>(
|
|
34
|
+
name: string,
|
|
35
|
+
definition: ComponentDefinition<S>,
|
|
36
|
+
): void {
|
|
37
|
+
_registry.set(name, definition as ComponentDefinition)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Type-helper — returns `definition` unchanged but lets TypeScript infer `S`
|
|
42
|
+
* from the `state` literal so all methods are typed with the correct `this`.
|
|
43
|
+
*
|
|
44
|
+
* Use this when defining a component outside a `define()` call.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const counter = defineComponent({
|
|
48
|
+
* state: { count: 0 },
|
|
49
|
+
* increment() { this.state.count++ }, // this.state: { count: number } ✓
|
|
50
|
+
* })
|
|
51
|
+
* Micra.define('counter', counter)
|
|
52
|
+
*/
|
|
53
|
+
export function defineComponent<S extends StateRecord>(
|
|
54
|
+
definition: ComponentDefinition<S>,
|
|
55
|
+
): ComponentDefinition<S> {
|
|
56
|
+
return definition
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns a read-only view of all live instances (keyed by root element).
|
|
61
|
+
* Useful for DevTools / debugging.
|
|
62
|
+
*/
|
|
63
|
+
export function instances(): ReadonlyMap<HTMLElement, ComponentInstance> {
|
|
64
|
+
return _instances
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns a read-only view of all registered component definitions.
|
|
69
|
+
* Useful for DevTools / debugging.
|
|
70
|
+
*/
|
|
71
|
+
export function registry(): ReadonlyMap<string, ComponentDefinition> {
|
|
72
|
+
return _registry
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Print all live component instances to the browser console.
|
|
77
|
+
* Shows component name, root element, and current state for each instance.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* // In browser DevTools console:
|
|
81
|
+
* Micra.debug()
|
|
82
|
+
* // [Micra] 3 live component(s)
|
|
83
|
+
* // counter $el: <div> state: { count: 5 }
|
|
84
|
+
* // user-list $el: <div> state: { users: [...], loading: false }
|
|
85
|
+
*/
|
|
86
|
+
export function debug(): void {
|
|
87
|
+
if (_instances.size === 0) {
|
|
88
|
+
console.log('[Micra] No live components.')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
console.group(`[Micra] ${_instances.size} live component(s)`)
|
|
92
|
+
for (const [el, instance] of _instances) {
|
|
93
|
+
const name = el.getAttribute('data-component') ?? '(unnamed)'
|
|
94
|
+
console.group(`%c${name}`, 'font-weight:bold;color:#6366f1')
|
|
95
|
+
console.log('$el ', el)
|
|
96
|
+
console.log('state', { ...instance.state })
|
|
97
|
+
console.groupEnd()
|
|
98
|
+
}
|
|
99
|
+
console.groupEnd()
|
|
100
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/start.ts — Auto-mount via [data-component].
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Scan the DOM (or a subtree) for [data-component] elements
|
|
6
|
+
* - Mount each using the registered definition
|
|
7
|
+
* - Skip already-mounted elements (safe to call multiple times)
|
|
8
|
+
* - Warn clearly when a component name is not registered
|
|
9
|
+
*
|
|
10
|
+
* LLM NOTE: start() is SSR-friendly — calling it multiple times is safe
|
|
11
|
+
* because mount() checks _instances before re-mounting.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { warn } from '../utils/expr'
|
|
15
|
+
import { _registry, _instances } from './registry'
|
|
16
|
+
import { mount } from './mount'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Scan for `[data-component]` elements and auto-mount registered definitions.
|
|
20
|
+
*
|
|
21
|
+
* Pass a subtree root to limit the scan (e.g., after a partial SSR update):
|
|
22
|
+
* `Micra.start(document.getElementById('panel'))`
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* // Mount everything on the page (called once after DOM ready)
|
|
26
|
+
* Micra.start()
|
|
27
|
+
*
|
|
28
|
+
* // Re-mount after injecting new HTML
|
|
29
|
+
* Micra.start(document.querySelector('#dynamic-section'))
|
|
30
|
+
*/
|
|
31
|
+
export function start(root: Document | HTMLElement = document): void {
|
|
32
|
+
root.querySelectorAll<HTMLElement>('[data-component]').forEach(el => {
|
|
33
|
+
if (_instances.has(el)) return // already mounted — skip
|
|
34
|
+
const name = el.getAttribute('data-component')!
|
|
35
|
+
const def = _registry.get(name)
|
|
36
|
+
if (!def) {
|
|
37
|
+
warn(`component "${name}" not defined. Call Micra.define('${name}', {...}) first.`)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
mount(el, def)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/dom/directives.ts — Apply DOM directives to a component subtree.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - data-text, data-html, data-if, data-show, data-bind, data-model
|
|
6
|
+
* - data-class (additive class toggling)
|
|
7
|
+
* - Directive result cache (built once per element, reused on re-renders)
|
|
8
|
+
*
|
|
9
|
+
* LLM NOTE: applyDirectives() is called on every render. The directive cache
|
|
10
|
+
* (DirectiveCache on el.__micraCache) avoids repeated querySelectorAll on
|
|
11
|
+
* re-renders — cache is built lazily on the first call for each root element.
|
|
12
|
+
*
|
|
13
|
+
* Important: this module does NOT handle data-each — see dom/each.ts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
CachedBinding,
|
|
18
|
+
DirectiveCache,
|
|
19
|
+
InternalInstance,
|
|
20
|
+
MicraElement,
|
|
21
|
+
StateRecord,
|
|
22
|
+
} from '../types'
|
|
23
|
+
import { evalExpr, warn } from '../utils/expr'
|
|
24
|
+
import { queryOwn, queryAll } from './query'
|
|
25
|
+
|
|
26
|
+
// ── Directive appliers ────────────────────────────────────────────────────────
|
|
27
|
+
// Each function is PURE relative to state — reads state, writes DOM.
|
|
28
|
+
|
|
29
|
+
function applyText(el: Element, expr: string, state: StateRecord): void {
|
|
30
|
+
const text = String(evalExpr(expr, state) ?? '')
|
|
31
|
+
if (el.textContent !== text) el.textContent = text
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function applyHtml(el: Element, expr: string, state: StateRecord): void {
|
|
35
|
+
el.innerHTML = String(evalExpr(expr, state) ?? '')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function applyIf(el: Element, expr: string, state: StateRecord): void {
|
|
39
|
+
(el as HTMLElement).style.display = evalExpr(expr, state) ? '' : 'none'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function applyBind(el: Element, expr: string, state: StateRecord): void {
|
|
43
|
+
for (const pair of expr.split(',')) {
|
|
44
|
+
const colonIdx = pair.indexOf(':')
|
|
45
|
+
if (colonIdx === -1) continue
|
|
46
|
+
const attr = pair.slice(0, colonIdx).trim()
|
|
47
|
+
const valExpr = pair.slice(colonIdx + 1).trim()
|
|
48
|
+
const val = evalExpr(valExpr, state)
|
|
49
|
+
|
|
50
|
+
if (attr === 'class') {
|
|
51
|
+
(el as HTMLElement).className = String(val ?? '')
|
|
52
|
+
} else if (attr === 'value') {
|
|
53
|
+
if (document.activeElement !== el)
|
|
54
|
+
(el as HTMLInputElement).value = String(val ?? '')
|
|
55
|
+
} else if (attr === 'style') {
|
|
56
|
+
if (typeof val === 'object' && val !== null) {
|
|
57
|
+
Object.assign((el as HTMLElement).style, val)
|
|
58
|
+
} else {
|
|
59
|
+
el.setAttribute('style', String(val ?? ''))
|
|
60
|
+
}
|
|
61
|
+
} else if (typeof val === 'boolean') {
|
|
62
|
+
val ? el.setAttribute(attr, '') : el.removeAttribute(attr)
|
|
63
|
+
} else {
|
|
64
|
+
val == null ? el.removeAttribute(attr) : el.setAttribute(attr, String(val))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* data-class="active:isActive, disabled:count === 0"
|
|
71
|
+
* Parses comma-separated `className:expression` pairs and toggles classes additively.
|
|
72
|
+
* Unlike data-bind="class:expr" this does NOT replace the full className.
|
|
73
|
+
*
|
|
74
|
+
* Syntax mirrors data-bind — split by comma, then by first colon.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* <div data-class="active:tab === 'home', hidden:!loaded">
|
|
78
|
+
*/
|
|
79
|
+
function applyClass(el: Element, expr: string, state: StateRecord): void {
|
|
80
|
+
for (const pair of expr.split(',')) {
|
|
81
|
+
const colonIdx = pair.indexOf(':')
|
|
82
|
+
if (colonIdx === -1) continue
|
|
83
|
+
const cls = pair.slice(0, colonIdx).trim()
|
|
84
|
+
const valExpr = pair.slice(colonIdx + 1).trim()
|
|
85
|
+
if (!cls) continue
|
|
86
|
+
el.classList.toggle(cls, Boolean(evalExpr(valExpr, state)))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function applyModel(
|
|
91
|
+
el: Element,
|
|
92
|
+
key: string,
|
|
93
|
+
rawState: StateRecord,
|
|
94
|
+
): void {
|
|
95
|
+
const html = el as HTMLInputElement
|
|
96
|
+
if (document.activeElement !== el) {
|
|
97
|
+
html.value = rawState[key] == null ? '' : String(rawState[key])
|
|
98
|
+
}
|
|
99
|
+
// listener is attached separately in events.ts — this only syncs the value
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Directive cache ───────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** @internal Collect all directive bindings for a root element. Built once. */
|
|
105
|
+
function buildCache(root: Element): DirectiveCache {
|
|
106
|
+
const pick = (attr: string): CachedBinding[] => {
|
|
107
|
+
const els = queryOwn(root, attr)
|
|
108
|
+
// Include root itself
|
|
109
|
+
if ((root as HTMLElement).hasAttribute?.(attr)) els.unshift(root)
|
|
110
|
+
return els
|
|
111
|
+
.filter(el => !el.closest('template'))
|
|
112
|
+
.map(el => ({ el, expr: el.getAttribute(attr)! }))
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
text: pick('data-text'),
|
|
116
|
+
html: pick('data-html'),
|
|
117
|
+
if: pick('data-if'),
|
|
118
|
+
show: pick('data-show'),
|
|
119
|
+
bind: pick('data-bind'),
|
|
120
|
+
model: pick('data-model'),
|
|
121
|
+
class: pick('data-class'),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Apply all non-each directives to a component subtree.
|
|
129
|
+
*
|
|
130
|
+
* For regular Elements: directive bindings are cached in `el.__micraCache`
|
|
131
|
+
* after the first call — subsequent re-renders skip querySelectorAll entirely.
|
|
132
|
+
*
|
|
133
|
+
* For DocumentFragments (no-key each clones): always re-scan because these
|
|
134
|
+
* fragments are new clones on every render.
|
|
135
|
+
*
|
|
136
|
+
* @param root - Component root Element or DocumentFragment (no-key each clone)
|
|
137
|
+
* @param state - Expression state (may include item/index for each rows)
|
|
138
|
+
* @param rawState - Raw (non-proxy) state for model sync
|
|
139
|
+
* @param instance - Component instance (unused here, kept for future hooks)
|
|
140
|
+
*/
|
|
141
|
+
export function applyDirectives<S extends StateRecord>(
|
|
142
|
+
root: Element | DocumentFragment,
|
|
143
|
+
state: StateRecord,
|
|
144
|
+
rawState: StateRecord,
|
|
145
|
+
_instance: InternalInstance<S>,
|
|
146
|
+
): void {
|
|
147
|
+
// DocumentFragments are temporary clones — always scan, never cache
|
|
148
|
+
if (root.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
|
|
149
|
+
applyFromList(buildFragmentList(root as DocumentFragment), state, rawState)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const el = root as MicraElement
|
|
154
|
+
if (!el.__micraCache) el.__micraCache = buildCache(el)
|
|
155
|
+
applyFromList(el.__micraCache, state, rawState)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @internal Apply a pre-built cache / binding list to current state. */
|
|
159
|
+
function applyFromList(
|
|
160
|
+
cache: DirectiveCache,
|
|
161
|
+
state: StateRecord,
|
|
162
|
+
rawState: StateRecord,
|
|
163
|
+
): void {
|
|
164
|
+
cache.text.forEach(b => applyText(b.el, b.expr, state))
|
|
165
|
+
cache.html.forEach(b => applyHtml(b.el, b.expr, state))
|
|
166
|
+
cache.if.forEach(b => applyIf(b.el, b.expr, state))
|
|
167
|
+
cache.show.forEach(b => applyIf(b.el, b.expr, state))
|
|
168
|
+
cache.bind.forEach(b => applyBind(b.el, b.expr, state))
|
|
169
|
+
cache.model.forEach(b => applyModel(b.el, b.expr.trim(), rawState))
|
|
170
|
+
cache.class.forEach(b => applyClass(b.el, b.expr, state))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @internal Scan a DocumentFragment (no-key each clone) — returns a DirectiveCache. */
|
|
174
|
+
function buildFragmentList(frag: DocumentFragment): DirectiveCache {
|
|
175
|
+
const pick = (attr: string): CachedBinding[] =>
|
|
176
|
+
queryAll(frag, `[${attr}]`)
|
|
177
|
+
.filter(el => !el.closest('template'))
|
|
178
|
+
.map(el => ({ el, expr: el.getAttribute(attr)! }))
|
|
179
|
+
return {
|
|
180
|
+
text: pick('data-text'),
|
|
181
|
+
html: pick('data-html'),
|
|
182
|
+
if: pick('data-if'),
|
|
183
|
+
show: pick('data-show'),
|
|
184
|
+
bind: pick('data-bind'),
|
|
185
|
+
model: pick('data-model'),
|
|
186
|
+
class: pick('data-class'),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Dev warning helper ────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Validate directive usage and emit dev warnings.
|
|
194
|
+
* Called once after the initial render of a component.
|
|
195
|
+
*
|
|
196
|
+
* @internal
|
|
197
|
+
*/
|
|
198
|
+
export function validateDirectives(root: Element): void {
|
|
199
|
+
queryOwn(root, 'data-each').forEach(el => {
|
|
200
|
+
if (!el.hasAttribute('data-key')) {
|
|
201
|
+
warn(`data-each="${el.getAttribute('data-each')}" has no data-key — keyed diff disabled. Add data-key="id" for better performance.`)
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Re-export warn for use in other modules
|
|
207
|
+
export { warn }
|
package/src/dom/each.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/dom/each.ts — Keyed and non-keyed list rendering (data-each).
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Process `<template data-each="items" data-key="id">` elements
|
|
6
|
+
* - Keyed diff: reuse/reorder DOM nodes by key — O(n) with a Map
|
|
7
|
+
* - Non-keyed fallback: full replace (no key → warn in dev, full re-render)
|
|
8
|
+
* - Apply directives to each row with a scoped itemState
|
|
9
|
+
*
|
|
10
|
+
* LLM NOTE: renderList() is called on every render cycle AFTER applyDirectives().
|
|
11
|
+
* Only <template> elements with data-each are processed.
|
|
12
|
+
* Keyed mode (data-key present) mutates the DOM in-place — nodes are
|
|
13
|
+
* created once and reused. Non-keyed mode removes all nodes and re-clones.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { InternalInstance, MicraElement, MicraTemplate, StateRecord } from '../types'
|
|
17
|
+
import { evalExpr, warn } from '../utils/expr'
|
|
18
|
+
import { applyDirectives } from './directives'
|
|
19
|
+
import { bindDataOn, bindAtEvents } from './events'
|
|
20
|
+
import { queryOwn, queryAll } from './query'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Process all `<template data-each>` elements owned by `root`.
|
|
24
|
+
* Scoped itemState makes `item`, `index`, `$index` available in row expressions.
|
|
25
|
+
*
|
|
26
|
+
* @param root - Component root Element
|
|
27
|
+
* @param state - Expression state (proxy merging rawState + instance)
|
|
28
|
+
* @param rawState - Raw (non-proxy) state — used for model binding
|
|
29
|
+
* @param instance - Component instance (for event binding)
|
|
30
|
+
*/
|
|
31
|
+
export function renderList<S extends StateRecord>(
|
|
32
|
+
root: Element,
|
|
33
|
+
state: StateRecord,
|
|
34
|
+
rawState: StateRecord,
|
|
35
|
+
instance: InternalInstance<S>,
|
|
36
|
+
): void {
|
|
37
|
+
queryOwn(root, 'data-each').forEach(tmplEl => {
|
|
38
|
+
if (tmplEl.tagName !== 'TEMPLATE') return
|
|
39
|
+
const tmpl = tmplEl as MicraTemplate
|
|
40
|
+
|
|
41
|
+
const itemsExpr = tmpl.getAttribute('data-each')!
|
|
42
|
+
const keyAttr = tmpl.getAttribute('data-key') ?? null
|
|
43
|
+
const items = evalExpr(itemsExpr, state)
|
|
44
|
+
|
|
45
|
+
// Ensure marker comment + internal state are initialized
|
|
46
|
+
if (!tmpl.__micraMarker) {
|
|
47
|
+
const m = document.createComment(`each:${itemsExpr}`)
|
|
48
|
+
tmpl.after(m)
|
|
49
|
+
tmpl.__micraMarker = m
|
|
50
|
+
tmpl.__micraNodes = new Map()
|
|
51
|
+
tmpl.__micraList = []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const marker = tmpl.__micraMarker
|
|
55
|
+
const keyMap = tmpl.__micraNodes
|
|
56
|
+
const parent = marker.parentNode!
|
|
57
|
+
|
|
58
|
+
// Empty / non-array: clear all rendered rows
|
|
59
|
+
if (!Array.isArray(items)) {
|
|
60
|
+
tmpl.__micraList.forEach(n => n.remove())
|
|
61
|
+
tmpl.__micraList = []
|
|
62
|
+
keyMap.clear()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (keyAttr) {
|
|
67
|
+
renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap, parent, state, rawState, instance)
|
|
68
|
+
} else {
|
|
69
|
+
renderNoKey(tmpl, items as StateRecord[], marker, parent, state, rawState, instance)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Keyed diff ────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
function renderKeyed<S extends StateRecord>(
|
|
77
|
+
tmpl: MicraTemplate,
|
|
78
|
+
items: StateRecord[],
|
|
79
|
+
keyAttr: string,
|
|
80
|
+
marker: Comment,
|
|
81
|
+
keyMap: Map<unknown, MicraElement>,
|
|
82
|
+
parent: Node,
|
|
83
|
+
state: StateRecord,
|
|
84
|
+
rawState: StateRecord,
|
|
85
|
+
instance: InternalInstance<S>,
|
|
86
|
+
): void {
|
|
87
|
+
const nextKeys = new Set<unknown>()
|
|
88
|
+
const nextNodes: MicraElement[] = []
|
|
89
|
+
|
|
90
|
+
for (const [index, item] of items.entries()) {
|
|
91
|
+
const key = item[keyAttr]
|
|
92
|
+
if (key == null) warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`)
|
|
93
|
+
nextKeys.add(key)
|
|
94
|
+
|
|
95
|
+
let node = keyMap.get(key) as MicraElement | undefined
|
|
96
|
+
|
|
97
|
+
if (!node) {
|
|
98
|
+
// Clone template and wrap multi-root fragments in a display:contents element
|
|
99
|
+
const frag = tmpl.content.cloneNode(true) as DocumentFragment
|
|
100
|
+
if (frag.childNodes.length === 1) {
|
|
101
|
+
node = frag.firstElementChild as MicraElement
|
|
102
|
+
} else {
|
|
103
|
+
node = document.createElement('micra-each-item') as MicraElement
|
|
104
|
+
node.style.display = 'contents'
|
|
105
|
+
node.append(frag)
|
|
106
|
+
}
|
|
107
|
+
node.__micraKey = key
|
|
108
|
+
keyMap.set(key, node)
|
|
109
|
+
// Bind data-on and @event handlers on the freshly created node (once)
|
|
110
|
+
bindDataOn(node, instance)
|
|
111
|
+
bindAtEvents(node, instance)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const itemState = Object.assign(
|
|
115
|
+
Object.create(state) as StateRecord,
|
|
116
|
+
{ item, index, $index: index },
|
|
117
|
+
)
|
|
118
|
+
applyDirectives(node, itemState, rawState, instance)
|
|
119
|
+
nextNodes.push(node)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Remove stale nodes
|
|
123
|
+
for (const [key, node] of keyMap) {
|
|
124
|
+
if (!nextKeys.has(key)) { node.remove(); keyMap.delete(key) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Insert / reorder nodes after marker (insertBefore is no-op if already in place)
|
|
128
|
+
let cursor: Node = marker
|
|
129
|
+
for (const node of nextNodes) {
|
|
130
|
+
if (cursor.nextSibling !== node) parent.insertBefore(node, cursor.nextSibling)
|
|
131
|
+
cursor = node
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
tmpl.__micraList = nextNodes
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Non-keyed (full re-render) ─────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
function renderNoKey<S extends StateRecord>(
|
|
140
|
+
tmpl: MicraTemplate,
|
|
141
|
+
items: StateRecord[],
|
|
142
|
+
marker: Comment,
|
|
143
|
+
parent: Node,
|
|
144
|
+
state: StateRecord,
|
|
145
|
+
rawState: StateRecord,
|
|
146
|
+
instance: InternalInstance<S>,
|
|
147
|
+
): void {
|
|
148
|
+
tmpl.__micraList.forEach(n => n.remove())
|
|
149
|
+
tmpl.__micraList = []
|
|
150
|
+
|
|
151
|
+
const frag = document.createDocumentFragment()
|
|
152
|
+
for (const [index, item] of items.entries()) {
|
|
153
|
+
const clone = tmpl.content.cloneNode(true) as DocumentFragment
|
|
154
|
+
const itemState = Object.assign(
|
|
155
|
+
Object.create(state) as StateRecord,
|
|
156
|
+
{ item, index, $index: index },
|
|
157
|
+
)
|
|
158
|
+
applyDirectives(clone, itemState, rawState, instance)
|
|
159
|
+
bindDataOn(clone as unknown as Element, instance)
|
|
160
|
+
bindAtEvents(clone as unknown as Element, instance)
|
|
161
|
+
|
|
162
|
+
const nodes = Array.from(clone.childNodes) as MicraElement[]
|
|
163
|
+
nodes.forEach(n => { n.__micraEach = true; frag.append(n) })
|
|
164
|
+
tmpl.__micraList.push(...nodes)
|
|
165
|
+
}
|
|
166
|
+
parent.insertBefore(frag, marker.nextSibling)
|
|
167
|
+
}
|