micra.js 2.1.0 → 2.2.1
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/CHANGELOG.md +244 -0
- package/README.md +39 -14
- package/dist/core/mount.d.ts +8 -2
- package/dist/core/reactive.d.ts +1 -1
- package/dist/core/registry.d.ts +13 -4
- package/dist/dom/directives.d.ts +11 -15
- package/dist/dom/each.d.ts +10 -7
- package/dist/dom/events.d.ts +15 -5
- package/dist/dom/refs.d.ts +5 -5
- package/dist/dom/scan.d.ts +34 -0
- package/dist/index.d.ts +1 -1
- package/dist/micra.cjs.js +302 -177
- package/dist/micra.cjs.js.map +4 -4
- package/dist/micra.esm.js +302 -177
- package/dist/micra.esm.js.map +4 -4
- package/dist/micra.js +302 -177
- package/dist/micra.js.map +4 -4
- package/dist/micra.min.js +2 -2
- package/dist/types.d.ts +67 -22
- package/llms-full.txt +600 -0
- package/llms.txt +148 -0
- package/package.json +11 -3
- package/src/core/mount.ts +136 -99
- package/src/core/reactive.ts +2 -1
- package/src/core/registry.ts +19 -9
- package/src/dom/directives.ts +39 -122
- package/src/dom/each.ts +133 -37
- package/src/dom/events.ts +23 -31
- package/src/dom/refs.ts +7 -7
- package/src/dom/scan.ts +189 -0
- package/src/index.ts +2 -0
- package/src/types.ts +80 -22
- package/src/utils/expr.ts +34 -21
package/llms.txt
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Micra.js
|
|
2
|
+
|
|
3
|
+
> Lightweight reactive TypeScript framework for server-rendered apps and small SaaS frontends. ~5 KB gzip. No build step required.
|
|
4
|
+
>
|
|
5
|
+
> **For LLM code generation:** read the expanded version at https://github.com/denisfl/micra.js/blob/master/llms-full.txt — it includes 10 full inline recipes and an anti-pattern reference. The recipes are the canonical answers to common requests like "build a todo app" or "build a search-with-debounce".
|
|
6
|
+
|
|
7
|
+
## When to use Micra.js instead of React/Vue
|
|
8
|
+
|
|
9
|
+
- You have a server-rendered page (Laravel, Rails, Django, etc.) and need a small amount of reactivity
|
|
10
|
+
- Bundle size matters (~5 KB gzip vs ~45 KB React)
|
|
11
|
+
- You don't need a full SPA or client-side routing
|
|
12
|
+
- You want to drop a `<script>` tag and go
|
|
13
|
+
- You need reactive directives on existing HTML without rewriting templates
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install micra.js
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import * as Micra from 'micra.js'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or via CDN (no build step):
|
|
26
|
+
|
|
27
|
+
```html
|
|
28
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This exposes a global `Micra` object.
|
|
32
|
+
|
|
33
|
+
> **CDN choice matters for AI sandboxes.** Use `cdn.jsdelivr.net` — it is in the
|
|
34
|
+
> Content Security Policy allowlist of Claude artifacts, ChatGPT canvas, and most
|
|
35
|
+
> other AI runtime environments. `unpkg.com` is NOT in those allowlists and will
|
|
36
|
+
> fail to load. jsDelivr auto-mirrors every npm package.
|
|
37
|
+
|
|
38
|
+
## Core pattern
|
|
39
|
+
|
|
40
|
+
```html
|
|
41
|
+
<div data-component="counter">
|
|
42
|
+
<span data-text="count"></span>
|
|
43
|
+
<button @click="increment">+</button>
|
|
44
|
+
</div>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
Micra.define('counter', {
|
|
49
|
+
state: { count: 0 },
|
|
50
|
+
increment() { this.state.count++ },
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
Micra.start()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Directives
|
|
57
|
+
|
|
58
|
+
| Directive | Example | Description |
|
|
59
|
+
|------------------|--------------------------------------|--------------------------------|
|
|
60
|
+
| `data-text` | `data-text="name"` | Set `textContent` |
|
|
61
|
+
| `data-html` | `data-html="content"` | Set `innerHTML` |
|
|
62
|
+
| `data-if` | `data-if="count > 0"` | Mount/**unmount** from DOM |
|
|
63
|
+
| `data-show` | `data-show="loaded"` | Toggle `style.display` only |
|
|
64
|
+
| `data-bind` | `data-bind="href:url, disabled:loading"` | Bind attributes |
|
|
65
|
+
| `data-model` | `data-model="search"` | Two-way input binding |
|
|
66
|
+
| `data-each` | `data-each="items" data-key="id"` | Keyed list rendering |
|
|
67
|
+
| `data-ref` | `data-ref="chart"` | DOM ref via `this.refs` |
|
|
68
|
+
| `data-class` | `data-class="active:isActive"` | Additive class toggling |
|
|
69
|
+
| `data-on` | `data-on="click:save"` | Bind DOM events |
|
|
70
|
+
| `@event` | `@click="increment"` | Shorthand event binding |
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// Register & mount
|
|
76
|
+
Micra.define(name, definition)
|
|
77
|
+
Micra.mount(selector, definition)
|
|
78
|
+
Micra.start(root?) // scans DOM for [data-component] and mounts all
|
|
79
|
+
|
|
80
|
+
// Event bus
|
|
81
|
+
Micra.on(event, handler)
|
|
82
|
+
Micra.emit(event, payload?)
|
|
83
|
+
Micra.off(event, handler)
|
|
84
|
+
|
|
85
|
+
// DevTools
|
|
86
|
+
Micra.instances() // ReadonlyMap of live components
|
|
87
|
+
Micra.registry() // ReadonlyMap of registered definitions
|
|
88
|
+
Micra.debug() // prints all live components to console
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Inside component methods, `this` exposes:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
this.$el // root HTMLElement
|
|
95
|
+
this.state // reactive state proxy
|
|
96
|
+
this.refs // { [name]: HTMLElement } from data-ref
|
|
97
|
+
this.render() // force re-render
|
|
98
|
+
this.destroy() // unmount
|
|
99
|
+
this.prop(name, default?) // read data-* attributes (SSR props)
|
|
100
|
+
this.fetch(url, options?) // fetch helper
|
|
101
|
+
this.emit(event, payload?) // component-scoped event emit
|
|
102
|
+
this.on(event, handler) // component-scoped event subscribe
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Lifecycle hooks
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
Micra.define('app', {
|
|
109
|
+
state: {},
|
|
110
|
+
onCreate() { /* mounted, refs available */ },
|
|
111
|
+
onDestroy() { /* cleanup */ },
|
|
112
|
+
})
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Rules for code generation
|
|
116
|
+
|
|
117
|
+
**Rule 0 — Artifact type override.** When working inside Claude artifacts, ChatGPT
|
|
118
|
+
canvas, v0, Replit AI, or similar "framework-aware" runtimes: those tools default
|
|
119
|
+
to React. When Micra.js is the requested library, **switch the artifact/canvas type
|
|
120
|
+
to plain HTML** (Claude: `application/vnd.ant.html`). Do NOT translate Micra recipes
|
|
121
|
+
into React "because the artifact is React" — change the artifact type instead.
|
|
122
|
+
Micra is a standalone-HTML library; React artifacts cannot host it.
|
|
123
|
+
|
|
124
|
+
When generating Micra.js code, DO NOT:
|
|
125
|
+
|
|
126
|
+
1. Use `getElementById`/`querySelector`/`innerHTML` for component output. Lists go through `<template data-each>` with `data-key`.
|
|
127
|
+
2. Store derived values (counts, totals, filtered subsets) as state fields. They must be methods called from directives.
|
|
128
|
+
3. Use `addEventListener` inside methods — it leaks past `destroy()`. Use `@event` / `data-on`.
|
|
129
|
+
4. Call `this.renderList()` / `this.refresh()` / `this.update()` after mutations — Micra batches a microtask render.
|
|
130
|
+
5. Write `state.user.name = x` — shallow proxy. Replace top-level: `state.user = {...state.user, name: x}`.
|
|
131
|
+
6. Use `@keydown.enter` — branch on `e.key === 'Enter'` in the method.
|
|
132
|
+
7. Use `data-model="filters.search"` — writes literal flat key, not nested. Keep models top-level.
|
|
133
|
+
|
|
134
|
+
Full anti-pattern list with side-by-side examples: [docs/llm-guide.md](https://github.com/denisfl/micra.js/blob/master/docs/llm-guide.md#anti-patterns-llms-gravitate-to-do-not)
|
|
135
|
+
|
|
136
|
+
## Docs
|
|
137
|
+
|
|
138
|
+
- LLM expanded context (read first for code generation): https://github.com/denisfl/micra.js/blob/master/llms-full.txt
|
|
139
|
+
- Full LLM guide: https://github.com/denisfl/micra.js/blob/master/docs/llm-guide.md
|
|
140
|
+
- Recipes (canonical full-app examples): https://github.com/denisfl/micra.js/tree/master/docs/recipes
|
|
141
|
+
- Live demo docs: https://denisfl.github.io/micra.js/
|
|
142
|
+
- Getting started: https://github.com/denisfl/micra.js/blob/master/docs/getting-started.md
|
|
143
|
+
- Directives: https://github.com/denisfl/micra.js/blob/master/docs/directives.md
|
|
144
|
+
- API reference: https://github.com/denisfl/micra.js/blob/master/docs/api-reference.md
|
|
145
|
+
- Examples: https://github.com/denisfl/micra.js/blob/master/docs/examples.md
|
|
146
|
+
- SSR: https://github.com/denisfl/micra.js/blob/master/docs/ssr.md
|
|
147
|
+
- npm: https://www.npmjs.com/package/micra.js
|
|
148
|
+
- GitHub: https://github.com/denisfl/micra.js
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "micra.js",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Lightweight reactive UI framework for server-rendered pages — reactive state, directives, event bus. < 5 KB gzip.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/micra.cjs.js",
|
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
|
-
"src"
|
|
19
|
+
"src",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"CHANGELOG.md",
|
|
23
|
+
"llms.txt",
|
|
24
|
+
"llms-full.txt"
|
|
20
25
|
],
|
|
21
26
|
"scripts": {
|
|
22
27
|
"build": "node build.mjs",
|
|
@@ -24,7 +29,10 @@
|
|
|
24
29
|
"dev": "node build.mjs --watch",
|
|
25
30
|
"test": "vitest run",
|
|
26
31
|
"test:watch": "vitest",
|
|
27
|
-
"test:coverage": "vitest run --coverage"
|
|
32
|
+
"test:coverage": "vitest run --coverage",
|
|
33
|
+
"docs:sync": "rm -rf site/dist && mkdir -p site/dist && cp -R dist/* site/dist/",
|
|
34
|
+
"docs:build": "npm run build && npm run docs:sync",
|
|
35
|
+
"docs:dev": "npm run docs:sync && npx serve -p 4321 site"
|
|
28
36
|
},
|
|
29
37
|
"devDependencies": {
|
|
30
38
|
"@vitest/coverage-v8": "^4.1.7",
|
package/src/core/mount.ts
CHANGED
|
@@ -15,21 +15,24 @@
|
|
|
15
15
|
import type {
|
|
16
16
|
ComponentDefinition,
|
|
17
17
|
ComponentInstance,
|
|
18
|
+
ComponentMethods,
|
|
18
19
|
EventHandler,
|
|
19
20
|
InternalInstance,
|
|
20
21
|
MicraElement,
|
|
22
|
+
|
|
21
23
|
StateRecord,
|
|
22
24
|
UnsubFn,
|
|
23
|
-
} from
|
|
24
|
-
import { warn } from
|
|
25
|
-
import { micraFetch } from
|
|
26
|
-
import { on as busOn, emit as busEmit } from
|
|
27
|
-
import { createReactiveState, createScheduler } from
|
|
28
|
-
import { applyDirectives, validateDirectives } from
|
|
29
|
-
import { renderList } from
|
|
30
|
-
import { bindDataOn, bindAtEvents, bindModels } from
|
|
31
|
-
import { collectRefs } from
|
|
32
|
-
import {
|
|
25
|
+
} from "../types";
|
|
26
|
+
import { warn } from "../utils/expr";
|
|
27
|
+
import { micraFetch } from "../utils/fetch";
|
|
28
|
+
import { on as busOn, emit as busEmit } from "../core/bus";
|
|
29
|
+
import { createReactiveState, createScheduler } from "../core/reactive";
|
|
30
|
+
import { applyDirectives, validateDirectives } from "../dom/directives";
|
|
31
|
+
import { renderList } from "../dom/each";
|
|
32
|
+
import { bindDataOn, bindAtEvents, bindModels } from "../dom/events";
|
|
33
|
+
import { collectRefs } from "../dom/refs";
|
|
34
|
+
import { scanComponent } from "../dom/scan";
|
|
35
|
+
import { _instances } from "../core/registry";
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* Mount a component definition onto a DOM element.
|
|
@@ -37,64 +40,82 @@ import { _instances } from '../core/registry'
|
|
|
37
40
|
*
|
|
38
41
|
* Already-mounted elements return the existing instance.
|
|
39
42
|
*
|
|
43
|
+
* Both `S` (state) and `M` (methods) are inferred from the literal — the
|
|
44
|
+
* returned instance is fully typed: `inst.state.X` and `inst.someMethod()`
|
|
45
|
+
* are checked.
|
|
46
|
+
*
|
|
40
47
|
* @example
|
|
41
48
|
* const instance = Micra.mount('#counter', {
|
|
42
49
|
* state: { count: 0 },
|
|
43
50
|
* inc() { this.state.count++ },
|
|
44
51
|
* })
|
|
52
|
+
* instance?.inc()
|
|
53
|
+
* instance?.state.count
|
|
45
54
|
*/
|
|
46
|
-
export function mount<S extends StateRecord>(
|
|
55
|
+
export function mount<S extends StateRecord, M>(
|
|
47
56
|
selector: string | HTMLElement,
|
|
48
|
-
definition: ComponentDefinition<S>,
|
|
49
|
-
): ComponentInstance<S> | null {
|
|
57
|
+
definition: ComponentDefinition<S, M>,
|
|
58
|
+
): ComponentInstance<S, M> | null {
|
|
50
59
|
const root =
|
|
51
|
-
typeof selector ===
|
|
60
|
+
typeof selector === "string"
|
|
52
61
|
? document.querySelector<HTMLElement>(selector)
|
|
53
|
-
: selector
|
|
62
|
+
: selector;
|
|
54
63
|
|
|
55
64
|
if (!root) {
|
|
56
|
-
warn(`"${selector}" not found`)
|
|
57
|
-
return null
|
|
65
|
+
warn(`"${selector}" not found`);
|
|
66
|
+
return null;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
// Already mounted — return existing instance without re-mounting
|
|
61
|
-
if (_instances.has(root))
|
|
70
|
+
if (_instances.has(root))
|
|
71
|
+
return _instances.get(root) as unknown as ComponentInstance<S, M>;
|
|
62
72
|
|
|
63
|
-
const rawState: StateRecord = { ...(definition.state ?? {}) }
|
|
64
|
-
const instance = { $el: root, refs: {} } as InternalInstance<S
|
|
73
|
+
const rawState: StateRecord = { ...(definition.state ?? {}) };
|
|
74
|
+
const instance = { $el: root, refs: {} } as InternalInstance<S>;
|
|
65
75
|
|
|
66
76
|
// Copy user-defined methods from definition to instance
|
|
67
|
-
for (const [key, val] of Object.entries(
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
for (const [key, val] of Object.entries(
|
|
78
|
+
definition as Record<string, unknown>,
|
|
79
|
+
)) {
|
|
80
|
+
if (key === "state" || key === "onCreate" || key === "onDestroy") continue;
|
|
81
|
+
if (typeof val === "function") instance[key] = val;
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
// ── prop() ────────────────────────────────────────────────────────────────
|
|
73
85
|
// Read data-* attributes from the root element with auto-cast.
|
|
74
86
|
instance.prop = function <T>(name: string, defaultVal?: T): T | undefined {
|
|
75
|
-
const val = root.dataset[name]
|
|
76
|
-
if (val === undefined) return defaultVal
|
|
77
|
-
if (val ===
|
|
78
|
-
if (val ===
|
|
79
|
-
if (val !==
|
|
80
|
-
return val as unknown as T
|
|
81
|
-
} as ComponentInstance<S>[
|
|
87
|
+
const val = root.dataset[name];
|
|
88
|
+
if (val === undefined) return defaultVal;
|
|
89
|
+
if (val === "true") return true as unknown as T;
|
|
90
|
+
if (val === "false") return false as unknown as T;
|
|
91
|
+
if (val !== "" && !isNaN(Number(val))) return Number(val) as unknown as T;
|
|
92
|
+
return val as unknown as T;
|
|
93
|
+
} as ComponentInstance<S>["prop"];
|
|
82
94
|
|
|
83
95
|
// ── fetch(), emit(), on() ─────────────────────────────────────────────────
|
|
84
|
-
instance.fetch = micraFetch
|
|
85
|
-
instance.emit
|
|
86
|
-
|
|
87
|
-
instance.on = <T = unknown>(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
instance.fetch = micraFetch;
|
|
97
|
+
instance.emit = busEmit;
|
|
98
|
+
|
|
99
|
+
instance.on = <T = unknown>(
|
|
100
|
+
event: string,
|
|
101
|
+
handler: EventHandler<T>,
|
|
102
|
+
): UnsubFn => {
|
|
103
|
+
const unsub = busOn(event, handler);
|
|
104
|
+
if (!instance.__micraSubs) instance.__micraSubs = [];
|
|
105
|
+
instance.__micraSubs.push(unsub);
|
|
106
|
+
return unsub;
|
|
107
|
+
};
|
|
93
108
|
|
|
94
109
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
95
|
-
let isRendering = false
|
|
96
|
-
|
|
97
|
-
|
|
110
|
+
let isRendering = false;
|
|
111
|
+
// Track which state key triggered the current render cycle.
|
|
112
|
+
// 'MULTIPLE' means more than one key was written before the microtask fired.
|
|
113
|
+
let _triggerKey: string | null | "MULTIPLE" = null;
|
|
114
|
+
const schedule = createScheduler(() => instance.render());
|
|
115
|
+
instance.state = createReactiveState(rawState, schedule, (key) => {
|
|
116
|
+
if (_triggerKey === null) _triggerKey = key;
|
|
117
|
+
else if (_triggerKey !== key) _triggerKey = "MULTIPLE";
|
|
118
|
+
}) as S;
|
|
98
119
|
|
|
99
120
|
// Expression state: proxy that falls back to instance methods so expressions
|
|
100
121
|
// like `data-text="formatDate(item.date)"` can call component methods.
|
|
@@ -106,90 +127,106 @@ export function mount<S extends StateRecord>(
|
|
|
106
127
|
// Both traps reject Object.prototype names ('constructor', 'toString', ...) —
|
|
107
128
|
// accessing them via a directive expression returns undefined instead of
|
|
108
129
|
// leaking the prototype.
|
|
109
|
-
const boundMethods = new Map<string, Function>()
|
|
130
|
+
const boundMethods = new Map<string, Function>();
|
|
110
131
|
const exprState = new Proxy(rawState, {
|
|
111
132
|
get(target, key: string) {
|
|
112
|
-
if (Object.prototype.hasOwnProperty.call(target, key)) return target[key]
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
if (Object.prototype.hasOwnProperty.call(target, key)) return target[key];
|
|
134
|
+
if (
|
|
135
|
+
Object.prototype.hasOwnProperty.call(instance, key) &&
|
|
136
|
+
typeof instance[key] === "function"
|
|
137
|
+
) {
|
|
138
|
+
const cached = boundMethods.get(key);
|
|
139
|
+
if (cached) return cached;
|
|
140
|
+
const bound = (instance[key] as Function).bind(instance);
|
|
141
|
+
boundMethods.set(key, bound);
|
|
142
|
+
return bound;
|
|
120
143
|
}
|
|
121
|
-
return undefined
|
|
144
|
+
return undefined;
|
|
122
145
|
},
|
|
123
146
|
has(target, key: string) {
|
|
124
|
-
if (typeof key !==
|
|
125
|
-
if (Object.prototype.hasOwnProperty.call(target, key)) return true
|
|
126
|
-
return
|
|
127
|
-
|
|
147
|
+
if (typeof key !== "string") return false;
|
|
148
|
+
if (Object.prototype.hasOwnProperty.call(target, key)) return true;
|
|
149
|
+
return (
|
|
150
|
+
Object.prototype.hasOwnProperty.call(instance, key) &&
|
|
151
|
+
typeof instance[key] === "function"
|
|
152
|
+
);
|
|
128
153
|
},
|
|
129
|
-
})
|
|
154
|
+
});
|
|
130
155
|
|
|
131
|
-
let warnedReentry = false
|
|
156
|
+
let warnedReentry = false;
|
|
132
157
|
instance.render = function () {
|
|
133
|
-
if (instance.__micraDestroyed) return
|
|
158
|
+
if (instance.__micraDestroyed) return;
|
|
159
|
+
const triggerKey = _triggerKey;
|
|
160
|
+
_triggerKey = null;
|
|
134
161
|
if (isRendering) {
|
|
135
162
|
if (!warnedReentry) {
|
|
136
|
-
warn(
|
|
137
|
-
|
|
163
|
+
warn(
|
|
164
|
+
"render() re-entry detected — mutation inside a directive expression is ignored. Move state writes to a method.",
|
|
165
|
+
);
|
|
166
|
+
warnedReentry = true;
|
|
138
167
|
}
|
|
139
|
-
return
|
|
168
|
+
return;
|
|
140
169
|
}
|
|
141
|
-
isRendering = true
|
|
170
|
+
isRendering = true;
|
|
142
171
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
172
|
+
// Single-pass scan, cached on the root for re-renders. Replaces what
|
|
173
|
+
// used to be ~10 separate querySelectorAll passes per render.
|
|
174
|
+
const mRoot = root as MicraElement;
|
|
175
|
+
const scan =
|
|
176
|
+
mRoot.__micraScan ?? (mRoot.__micraScan = scanComponent(root));
|
|
177
|
+
applyDirectives(scan, exprState, rawState, instance);
|
|
178
|
+
renderList(scan.each, exprState, rawState, instance, triggerKey);
|
|
179
|
+
bindDataOn(scan.on, instance);
|
|
180
|
+
bindAtEvents(scan.atEvents, instance);
|
|
181
|
+
bindModels(scan.model, instance);
|
|
182
|
+
collectRefs(scan.refs, instance);
|
|
149
183
|
} finally {
|
|
150
|
-
isRendering = false
|
|
184
|
+
isRendering = false;
|
|
151
185
|
}
|
|
152
|
-
}
|
|
186
|
+
};
|
|
153
187
|
|
|
154
188
|
// ── Destroy ───────────────────────────────────────────────────────────────
|
|
155
189
|
instance.destroy = function () {
|
|
156
|
-
if (instance.__micraDestroyed) return
|
|
157
|
-
instance.__micraDestroyed = true
|
|
190
|
+
if (instance.__micraDestroyed) return;
|
|
191
|
+
instance.__micraDestroyed = true;
|
|
158
192
|
|
|
159
193
|
// Remove every DOM listener attached by bindDataOn / bindAtEvents / bindModels.
|
|
160
|
-
instance.__micraListeners?.forEach(({ el, type, fn }) =>
|
|
161
|
-
|
|
194
|
+
instance.__micraListeners?.forEach(({ el, type, fn }) =>
|
|
195
|
+
el.removeEventListener(type, fn),
|
|
196
|
+
);
|
|
197
|
+
instance.__micraListeners = [];
|
|
162
198
|
|
|
163
|
-
// Clear per-element flags & cached
|
|
199
|
+
// Clear per-element flags & cached scan so a future re-mount of the same DOM works.
|
|
164
200
|
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.
|
|
170
|
-
}
|
|
171
|
-
clearFlags(root)
|
|
172
|
-
root.querySelectorAll(
|
|
173
|
-
|
|
174
|
-
instance.__micraSubs?.forEach(unsub => unsub())
|
|
175
|
-
instance.__micraSubs = []
|
|
176
|
-
|
|
177
|
-
if (typeof (definition as Record<string, unknown>).onDestroy ===
|
|
178
|
-
(definition.onDestroy as () => void).call(instance)
|
|
179
|
-
_instances.delete(root)
|
|
180
|
-
}
|
|
201
|
+
const m = el as MicraElement;
|
|
202
|
+
delete m.__micraEvents;
|
|
203
|
+
delete m.__micraAtBound;
|
|
204
|
+
delete m.__micraModel;
|
|
205
|
+
delete m.__micraScan;
|
|
206
|
+
};
|
|
207
|
+
clearFlags(root);
|
|
208
|
+
root.querySelectorAll("*").forEach(clearFlags);
|
|
209
|
+
|
|
210
|
+
instance.__micraSubs?.forEach((unsub) => unsub());
|
|
211
|
+
instance.__micraSubs = [];
|
|
212
|
+
|
|
213
|
+
if (typeof (definition as Record<string, unknown>).onDestroy === "function")
|
|
214
|
+
(definition.onDestroy as () => void).call(instance);
|
|
215
|
+
_instances.delete(root);
|
|
216
|
+
};
|
|
181
217
|
|
|
182
218
|
// ── Bootstrap ─────────────────────────────────────────────────────────────
|
|
183
|
-
_instances.set(root, instance as InternalInstance)
|
|
184
|
-
instance.render()
|
|
219
|
+
_instances.set(root, instance as InternalInstance);
|
|
220
|
+
instance.render();
|
|
185
221
|
|
|
186
|
-
// Validate directive usage and emit dev warnings
|
|
187
|
-
|
|
222
|
+
// Validate directive usage and emit dev warnings — reuses the same scan.
|
|
223
|
+
const mRoot = root as MicraElement;
|
|
224
|
+
if (mRoot.__micraScan) validateDirectives(mRoot.__micraScan);
|
|
188
225
|
|
|
189
|
-
if (typeof (definition as Record<string, unknown>).onCreate ===
|
|
226
|
+
if (typeof (definition as Record<string, unknown>).onCreate === "function")
|
|
190
227
|
Promise.resolve().then(() =>
|
|
191
228
|
(definition.onCreate as () => void | Promise<void>).call(instance),
|
|
192
|
-
)
|
|
229
|
+
);
|
|
193
230
|
|
|
194
|
-
return instance
|
|
231
|
+
return instance as unknown as ComponentInstance<S, M>;
|
|
195
232
|
}
|
package/src/core/reactive.ts
CHANGED
|
@@ -20,11 +20,12 @@ import type { StateRecord } from '../types'
|
|
|
20
20
|
* const state = createReactiveState(raw, render)
|
|
21
21
|
* state.count = 5 // triggers render() in next microtask
|
|
22
22
|
*/
|
|
23
|
-
export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void): S {
|
|
23
|
+
export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void, onKey?: (key: string) => void): S {
|
|
24
24
|
return new Proxy(obj, {
|
|
25
25
|
set(target, key: string, value: unknown) {
|
|
26
26
|
// Cast through StateRecord — TypeScript cannot write through a generic index
|
|
27
27
|
;(target as StateRecord)[key] = value
|
|
28
|
+
onKey?.(key)
|
|
28
29
|
schedule()
|
|
29
30
|
return true
|
|
30
31
|
},
|
package/src/core/registry.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type {
|
|
13
13
|
ComponentDefinition,
|
|
14
14
|
ComponentInstance,
|
|
15
|
+
ComponentMethods,
|
|
15
16
|
InternalInstance,
|
|
16
17
|
StateRecord,
|
|
17
18
|
} from '../types'
|
|
@@ -27,19 +28,28 @@ export const _instances = new Map<HTMLElement, InternalInstance>()
|
|
|
27
28
|
/**
|
|
28
29
|
* Register a component definition under `name`.
|
|
29
30
|
*
|
|
31
|
+
* Both state shape (`S`) and method set (`M`) are inferred from the literal,
|
|
32
|
+
* so `this.state.X` and `this.someMethod()` are fully typed inside the
|
|
33
|
+
* method bodies and lifecycle hooks.
|
|
34
|
+
*
|
|
30
35
|
* @example
|
|
31
|
-
* define('counter', {
|
|
36
|
+
* define('counter', {
|
|
37
|
+
* state: { count: 0 },
|
|
38
|
+
* inc() { this.state.count++ }, // this.state.count: number ✓
|
|
39
|
+
* reset() { this.state.count = 0 }, // this.reset() is also typed ✓
|
|
40
|
+
* onCreate() { this.inc() }, // ✓
|
|
41
|
+
* })
|
|
32
42
|
*/
|
|
33
|
-
export function define<S extends StateRecord>(
|
|
43
|
+
export function define<S extends StateRecord, M>(
|
|
34
44
|
name: string,
|
|
35
|
-
definition: ComponentDefinition<S>,
|
|
45
|
+
definition: ComponentDefinition<S, M>,
|
|
36
46
|
): void {
|
|
37
|
-
_registry.set(name, definition as ComponentDefinition)
|
|
47
|
+
_registry.set(name, definition as unknown as ComponentDefinition)
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
/**
|
|
41
51
|
* Type-helper — returns `definition` unchanged but lets TypeScript infer `S`
|
|
42
|
-
* from the
|
|
52
|
+
* and `M` from the literal so all methods are typed with the correct `this`.
|
|
43
53
|
*
|
|
44
54
|
* Use this when defining a component outside a `define()` call.
|
|
45
55
|
*
|
|
@@ -50,9 +60,9 @@ export function define<S extends StateRecord>(
|
|
|
50
60
|
* })
|
|
51
61
|
* Micra.define('counter', counter)
|
|
52
62
|
*/
|
|
53
|
-
export function defineComponent<S extends StateRecord>(
|
|
54
|
-
definition: ComponentDefinition<S>,
|
|
55
|
-
): ComponentDefinition<S> {
|
|
63
|
+
export function defineComponent<S extends StateRecord, M>(
|
|
64
|
+
definition: ComponentDefinition<S, M>,
|
|
65
|
+
): ComponentDefinition<S, M> {
|
|
56
66
|
return definition
|
|
57
67
|
}
|
|
58
68
|
|
|
@@ -61,7 +71,7 @@ export function defineComponent<S extends StateRecord>(
|
|
|
61
71
|
* Useful for DevTools / debugging.
|
|
62
72
|
*/
|
|
63
73
|
export function instances(): ReadonlyMap<HTMLElement, ComponentInstance> {
|
|
64
|
-
return _instances
|
|
74
|
+
return _instances as unknown as ReadonlyMap<HTMLElement, ComponentInstance>
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
/**
|