microtags 0.0.1 → 0.0.5
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 +508 -89
- package/dist/chunk-7QVYU63E.js +7 -0
- package/dist/chunk-7QVYU63E.js.map +7 -0
- package/dist/chunk-CCYWRJW6.js +98 -0
- package/dist/chunk-CCYWRJW6.js.map +7 -0
- package/dist/chunk-E7ZGOHL3.js +51 -0
- package/dist/chunk-E7ZGOHL3.js.map +7 -0
- package/dist/context.cjs +10 -4
- package/dist/context.cjs.map +3 -3
- package/dist/context.js +7 -63
- package/dist/context.js.map +3 -3
- package/dist/index.cjs +416 -5
- package/dist/index.cjs.map +3 -3
- package/dist/index.js +345 -2
- package/dist/index.js.map +3 -3
- package/dist/meta.json +276 -26
- package/dist/render.cjs +6 -4
- package/dist/render.cjs.map +2 -2
- package/dist/render.d.ts +4 -6
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +9 -6
- package/dist/render.js.map +2 -2
- package/dist/render.min.js +1 -1
- package/dist/render.min.js.map +2 -2
- package/dist/util.cjs +68 -0
- package/dist/util.cjs.map +7 -0
- package/dist/util.js +16 -0
- package/dist/util.js.map +7 -0
- package/dist/util.min.js +2 -0
- package/dist/util.min.js.map +7 -0
- package/package.json +26 -14
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# microtags
|
|
2
2
|
|
|
3
|
-
](https://github.com/nichoth/microtags/actions/workflows/nodejs.yml)
|
|
4
4
|
[](README.md)
|
|
5
5
|
[](README.md)
|
|
6
6
|
[](https://semver.org/)
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
[](https://bundlephobia.com/package/microtags)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
|
|
12
|
-
Web component helper with reactive props, automatic cleanup,
|
|
13
|
-
|
|
14
|
-
[alien-signals](https://github.com/stackblitz/alien-signals).
|
|
12
|
+
Web component helper with reactive props, automatic cleanup, and no
|
|
13
|
+
[Shadow DOM](https://gomakethings.com/the-shadow-dom-is-an-antipattern/).
|
|
14
|
+
Reactive via [alien-signals](https://github.com/stackblitz/alien-signals).
|
|
15
|
+
|
|
16
|
+
**Size**: 3.41 kB with all dependencies, minified and brotlied
|
|
15
17
|
|
|
16
18
|
Inspired by [nanotags](https://nanotags.psdcoder.dev/). Reactive props use
|
|
17
19
|
**alien-signals** instead of nanostores atoms.
|
|
@@ -21,30 +23,85 @@ Inspired by [nanotags](https://nanotags.psdcoder.dev/). Reactive props use
|
|
|
21
23
|
<!-- toc -->
|
|
22
24
|
|
|
23
25
|
- [Install](#install)
|
|
26
|
+
* [Import map](#import-map)
|
|
27
|
+
* [Pre-bundled](#pre-bundled)
|
|
24
28
|
- [Example](#example)
|
|
29
|
+
* [Client-side validation](#client-side-validation)
|
|
25
30
|
- [Serverside Example](#serverside-example)
|
|
31
|
+
* [`.TAG`](#tag)
|
|
32
|
+
* [`.refs`](#refs)
|
|
26
33
|
- [API](#api)
|
|
27
34
|
* [Props](#props)
|
|
28
|
-
|
|
29
|
-
* [
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
* [
|
|
33
|
-
|
|
34
|
-
* [
|
|
35
|
-
|
|
35
|
+
+ [`withProps`](#withprops)
|
|
36
|
+
* [Refs](#refs)
|
|
37
|
+
+ [`withRefs`](#withrefs)
|
|
38
|
+
+ [Element types](#element-types)
|
|
39
|
+
* [`define`](#define)
|
|
40
|
+
* [`withContexts`](#withcontexts)
|
|
41
|
+
* [`setup` and `ctx`](#setup-and-ctx)
|
|
42
|
+
+ [peek and untracked](#peek-and-untracked)
|
|
43
|
+
- [Templates](#templates)
|
|
44
|
+
- [Subpath Exports](#subpath-exports)
|
|
45
|
+
* [`microtags/context`](#microtagscontext)
|
|
46
|
+
* [`microtags/render`](#microtagsrender)
|
|
47
|
+
+ [`renderList`](#renderlist)
|
|
48
|
+
+ [`render`](#render)
|
|
49
|
+
+ [Templates](#templates-1)
|
|
36
50
|
- [Divergence from nanotags](#divergence-from-nanotags)
|
|
37
51
|
|
|
38
52
|
<!-- tocstop -->
|
|
39
53
|
|
|
40
54
|
</details>
|
|
41
55
|
|
|
56
|
+
----------------------------------------------------------------------
|
|
42
57
|
## Install
|
|
58
|
+
----------------------------------------------------------------------
|
|
43
59
|
|
|
44
60
|
```sh
|
|
45
61
|
npm i -S microtags
|
|
46
62
|
```
|
|
47
63
|
|
|
64
|
+
### Import map
|
|
65
|
+
|
|
66
|
+
No build step needed. Resolve `microtags` with an
|
|
67
|
+
[import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap)
|
|
68
|
+
and `esm.sh`.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
```html
|
|
72
|
+
<script type="importmap">
|
|
73
|
+
{
|
|
74
|
+
"imports": {
|
|
75
|
+
"microtags": "https://esm.sh/microtags"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<script type="module">
|
|
81
|
+
import { define } from 'microtags'
|
|
82
|
+
</script>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Subpaths follow the same URL pattern: `microtags/context`, `microtags/render`.
|
|
86
|
+
|
|
87
|
+
### Pre-bundled
|
|
88
|
+
|
|
89
|
+
For raw-file CDNs (jsDelivr, unpkg) or local files, where dependencies are
|
|
90
|
+
not resolved for you, use the pre-bundled `microtags/min` build. It is a
|
|
91
|
+
single self-contained file with `alien-signals` inlined, so no other
|
|
92
|
+
mappings are needed:
|
|
93
|
+
|
|
94
|
+
```html
|
|
95
|
+
<script type="importmap">
|
|
96
|
+
{
|
|
97
|
+
"imports": {
|
|
98
|
+
"microtags": "https://cdn.jsdelivr.net/npm/microtags/dist/index.min.js"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
</script>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
|
|
48
105
|
----------------------------------------------------------------------
|
|
49
106
|
## Example
|
|
50
107
|
----------------------------------------------------------------------
|
|
@@ -54,13 +111,13 @@ to do client-side-rendered components, that is possible. You would update the
|
|
|
54
111
|
DOM inside `.setup`.
|
|
55
112
|
|
|
56
113
|
Since the full HTML exists already, defining your components in the
|
|
57
|
-
client-side JS is about "hydrating" the page
|
|
114
|
+
client-side JS is about "hydrating" the page (adding behavior).
|
|
58
115
|
|
|
59
116
|
---
|
|
60
117
|
|
|
61
118
|
`withProps`, `withRefs`, and `withContexts` are optional and can appear in
|
|
62
|
-
any order. setup ends the chain: it calls `customElements.define` under the
|
|
63
|
-
and returns a typed constructor.
|
|
119
|
+
any order. `.setup` ends the chain: it calls `customElements.define` under the
|
|
120
|
+
hood and returns a typed constructor.
|
|
64
121
|
|
|
65
122
|
```ts
|
|
66
123
|
import { define } from 'microtags'
|
|
@@ -117,36 +174,130 @@ export const myCounter = define('my-counter')
|
|
|
117
174
|
</my-counter>
|
|
118
175
|
```
|
|
119
176
|
|
|
177
|
+
### Client-side validation
|
|
178
|
+
|
|
179
|
+
See [example/subscribe-form.ts](./example/subscribe-form.ts).
|
|
180
|
+
|
|
181
|
+
There are two separate things people call "validation", and `microtags`
|
|
182
|
+
handles them in different places:
|
|
183
|
+
|
|
184
|
+
1. **Prop validation** checks the attributes passed *into* a component.
|
|
185
|
+
Pass a [Standard Schema](https://standardschema.dev) (Zod, Valibot,
|
|
186
|
+
ArkType) to [`p.schema`](#withprops); it coerces the attribute value
|
|
187
|
+
and falls back to `undefined` when the value doesn't match.
|
|
188
|
+
2. **Form input validation** checks live user input in a form, for example a
|
|
189
|
+
value that changes on every keystroke and needs to report *why* it is invalid.
|
|
190
|
+
There is no dedicated API for this. Validate the schema directly
|
|
191
|
+
inside `.setup()` and hold the result in a signal:
|
|
192
|
+
|
|
193
|
+
The example below is the second kind -- form input (client-side) validation.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { signal, startBatch, endBatch } from 'alien-signals'
|
|
197
|
+
import { z } from 'zod'
|
|
198
|
+
import { define } from 'microtags'
|
|
199
|
+
|
|
200
|
+
const Email = z.email('Please enter a valid email address.')
|
|
201
|
+
|
|
202
|
+
define('subscribe-form')
|
|
203
|
+
.withRefs(r => ({
|
|
204
|
+
form: r.one('form'),
|
|
205
|
+
input: r.one('input'),
|
|
206
|
+
submit: r.one('button'),
|
|
207
|
+
error: r.one('.error'),
|
|
208
|
+
status: r.one('.status'),
|
|
209
|
+
}))
|
|
210
|
+
.setup(ctx => {
|
|
211
|
+
const value = signal('')
|
|
212
|
+
const touched = signal(false)
|
|
213
|
+
|
|
214
|
+
// capture the live value (two-way; clears the field on reset)
|
|
215
|
+
ctx.bind(value, ctx.refs.input)
|
|
216
|
+
// only reveal errors after the first blur
|
|
217
|
+
ctx.on(ctx.refs.input, 'blur', () => touched(true))
|
|
218
|
+
|
|
219
|
+
ctx.effect(() => {
|
|
220
|
+
const result = Email.safeParse(value())
|
|
221
|
+
const message = result.success ?
|
|
222
|
+
'' :
|
|
223
|
+
result.error.issues[0].message
|
|
224
|
+
const showError = touched() && !result.success
|
|
225
|
+
|
|
226
|
+
ctx.refs.submit.disabled = !result.success
|
|
227
|
+
ctx.refs.input.setAttribute('aria-invalid', String(showError))
|
|
228
|
+
ctx.refs.error.textContent = showError ? message : ''
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
ctx.on(ctx.refs.form, 'submit', ev => {
|
|
232
|
+
ev.preventDefault()
|
|
233
|
+
if (!Email.safeParse(value()).success) return
|
|
234
|
+
ctx.refs.status.textContent = 'Thanks for subscribing!'
|
|
235
|
+
startBatch()
|
|
236
|
+
value('')
|
|
237
|
+
touched(false)
|
|
238
|
+
endBatch()
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The error stays hidden until the field is blurred once, then updates live;
|
|
244
|
+
the submit button is gated on validity. See `example/subscribe-form.ts`
|
|
245
|
+
for the full working component.
|
|
246
|
+
|
|
120
247
|
|
|
121
248
|
----------------------------------------------------------------------
|
|
122
249
|
## Serverside Example
|
|
123
250
|
----------------------------------------------------------------------
|
|
124
251
|
|
|
252
|
+
### `.TAG`
|
|
253
|
+
|
|
125
254
|
Each tag name that you pass to `define` is exposed as a property `.TAG`
|
|
126
255
|
on the return value. It is possible to import your web components in Node,
|
|
127
256
|
even though Node does not have browser APIs.
|
|
128
257
|
|
|
258
|
+
### `.refs`
|
|
259
|
+
|
|
260
|
+
All refs are defined per-component, and the ref names are exposed at `.refs`,
|
|
261
|
+
so you can import your web components on the server and read the ref names
|
|
262
|
+
as strings.
|
|
263
|
+
|
|
129
264
|
```ts
|
|
130
|
-
import {
|
|
265
|
+
import { MyCounter, CountBtn, CopyBtn } from './index.js'
|
|
131
266
|
|
|
132
267
|
// this runs in Node
|
|
133
268
|
|
|
134
269
|
export function render ():string {
|
|
135
|
-
return
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
270
|
+
return `<div>
|
|
271
|
+
<${MyCounter.TAG} start="${START}">
|
|
272
|
+
<div data-ref="${MyCounter.refs.display}"
|
|
273
|
+
role="status"
|
|
274
|
+
class="count"
|
|
275
|
+
>${START}</div>
|
|
139
276
|
|
|
140
277
|
<div class="controls">
|
|
141
|
-
<${
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
278
|
+
<${CountBtn.TAG}
|
|
279
|
+
class="dec"
|
|
280
|
+
data-ref="${MyCounter.refs.dec}"
|
|
281
|
+
aria-label="Decrement"
|
|
282
|
+
>
|
|
283
|
+
<button>-</button>
|
|
284
|
+
</${CountBtn.TAG}>
|
|
285
|
+
|
|
286
|
+
<${CopyBtn.TAG}
|
|
287
|
+
class="copy"
|
|
288
|
+
data-ref="${MyCounter.refs.copy}"
|
|
289
|
+
></${CopyBtn.TAG}>
|
|
290
|
+
|
|
291
|
+
<${CountBtn.TAG}
|
|
292
|
+
class="inc"
|
|
293
|
+
data-ref="${MyCounter.refs.inc}"
|
|
294
|
+
aria-label="Increment"
|
|
295
|
+
>
|
|
296
|
+
<button>+</button>
|
|
297
|
+
</${CountBtn.TAG}>
|
|
146
298
|
</div>
|
|
147
|
-
</${
|
|
148
|
-
</div
|
|
149
|
-
`
|
|
299
|
+
</${MyCounter.TAG}>
|
|
300
|
+
</div>`
|
|
150
301
|
}
|
|
151
302
|
```
|
|
152
303
|
|
|
@@ -156,7 +307,7 @@ export function render ():string {
|
|
|
156
307
|
|
|
157
308
|
### Props
|
|
158
309
|
|
|
159
|
-
Declare reactive attributes via withProps
|
|
310
|
+
Declare reactive attributes via `withProps`. Each prop becomes:
|
|
160
311
|
|
|
161
312
|
* An observed HTML attribute (auto-synced via `attributeChangedCallback`)
|
|
162
313
|
* A signal at `ctx.props.propName`
|
|
@@ -170,27 +321,17 @@ Declare reactive attributes via withProps. Each prop becomes:
|
|
|
170
321
|
| `p.boolean()` | `"true"` / `""` → `true`, `"false"` → `false` | `false` |
|
|
171
322
|
| `p.oneOf(opts)` | Picklist enum, throws on invalid | throws |
|
|
172
323
|
|
|
173
|
-
### define
|
|
174
|
-
|
|
175
|
-
```ts
|
|
176
|
-
import { define } from 'microtags'
|
|
177
324
|
|
|
178
|
-
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
Returns a fluent builder. Call `.setup()` to register the custom element.
|
|
182
|
-
All builder methods are chainable and fully type-inferred.
|
|
183
|
-
|
|
184
|
-
### withProps
|
|
325
|
+
#### `withProps`
|
|
185
326
|
|
|
186
327
|
```ts
|
|
187
328
|
.withProps(p => ({
|
|
188
|
-
count: p.number(),
|
|
189
|
-
label: p.string(),
|
|
190
|
-
active: p.boolean(),
|
|
191
|
-
data: p.json<MyType>(),
|
|
192
|
-
schema: p.schema(zodSchema), // Standard Schema (Zod, Valibot, ArkType
|
|
193
|
-
value: p.prop(0),
|
|
329
|
+
count: p.number(), // attribute -> number (NaN when absent)
|
|
330
|
+
label: p.string(), // attribute -> string ('' when absent)
|
|
331
|
+
active: p.boolean(), // attribute -> boolean (true if present)
|
|
332
|
+
data: p.json<MyType>(), // attribute -> JSON.parse or undefined
|
|
333
|
+
schema: p.schema(zodSchema), // Standard Schema (Zod, Valibot, ArkType)
|
|
334
|
+
value: p.prop(0), // property-only, not an observed attribute
|
|
194
335
|
}))
|
|
195
336
|
```
|
|
196
337
|
|
|
@@ -201,18 +342,30 @@ ctx.props.count() // read
|
|
|
201
342
|
ctx.props.count(42) // write
|
|
202
343
|
```
|
|
203
344
|
|
|
204
|
-
|
|
345
|
+
|
|
346
|
+
### Refs
|
|
347
|
+
|
|
348
|
+
The `data-ref` attribute is a fallback. It's used when you don't pass a
|
|
349
|
+
selector to `r.one()`. If you pass explicit CSS selectors to `withRefs`,
|
|
350
|
+
e.g. `{ copy: r.one('button') }`, then the HTML does not require a `data-ref`
|
|
351
|
+
attribute.
|
|
352
|
+
|
|
353
|
+
#### `withRefs`
|
|
354
|
+
|
|
355
|
+
Call `r.one()` / `r.all()` with no argument to match `[data-ref="<key>"]`,
|
|
356
|
+
where `key` is the key name you pass in, or pass a CSS selector to query by
|
|
357
|
+
it instead. Queries are resolved relative to the host element, during
|
|
358
|
+
[the `connectedCallback`](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks).
|
|
359
|
+
|
|
205
360
|
|
|
206
361
|
```ts
|
|
207
362
|
.withRefs(r => ({
|
|
208
|
-
button: r.one(), // resolves [data-ref="button"]
|
|
209
|
-
items: r.all(), // resolves all [data-ref="items"]
|
|
363
|
+
button: r.one(), // resolves [data-ref="button"] (throws if missing)
|
|
364
|
+
items: r.all(), // resolves all [data-ref="items"] (empty array if none)
|
|
210
365
|
}))
|
|
211
366
|
```
|
|
212
367
|
|
|
213
|
-
|
|
214
|
-
or pass a CSS selector to query by it instead — resolved relative to the
|
|
215
|
-
host element:
|
|
368
|
+
If there are no `data-ref` attribtues in the HTML, use CSS query selectors.
|
|
216
369
|
|
|
217
370
|
```ts
|
|
218
371
|
.withRefs(r => ({
|
|
@@ -221,7 +374,9 @@ host element:
|
|
|
221
374
|
}))
|
|
222
375
|
```
|
|
223
376
|
|
|
224
|
-
|
|
377
|
+
See [#refs in serverside-rendering](#refs). Each component created with
|
|
378
|
+
`micortags` exposes a `.refs` property that is an object map
|
|
379
|
+
of ref names, eg `MyComponent.refs.copyButton`.
|
|
225
380
|
|
|
226
381
|
#### Element types
|
|
227
382
|
|
|
@@ -255,35 +410,84 @@ Non-HTML refs (SVG / MathML) are out of scope: the type parameter is
|
|
|
255
410
|
constrained to `HTMLElement`, so cast at the use site if you need one
|
|
256
411
|
(e.g. `ctx.refs.icon as unknown as SVGSVGElement`).
|
|
257
412
|
|
|
258
|
-
|
|
413
|
+
|
|
414
|
+
### `define`
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { define } from 'microtags'
|
|
418
|
+
|
|
419
|
+
define(tagName:string):ComponentBuilder
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Returns a fluent builder. Call `.setup()` to register the custom element.
|
|
423
|
+
All builder methods are chainable and fully type-inferred.
|
|
424
|
+
|
|
425
|
+
### `withContexts`
|
|
426
|
+
|
|
427
|
+
Context is for the case where a parent component defines some state, e.g. a
|
|
428
|
+
`theme`, and the top-level (application) knows nothing about the theme. But the
|
|
429
|
+
top level application does determine what children the parent renders
|
|
430
|
+
(it passes in children), and the children need to know the theme.
|
|
431
|
+
|
|
432
|
+
In that case, there is no way for the "parent" component to pass the theme to
|
|
433
|
+
the children, and the root level (application) doesn't know what the theme is.
|
|
434
|
+
|
|
435
|
+
Context gives us a "pull" based way to model this. The context-provider
|
|
436
|
+
publishes the value, and any descendants opt in (or pull values) with
|
|
437
|
+
`withContexts`, and the unknown-consumer and late-arrival problems are handled.
|
|
438
|
+
|
|
439
|
+
>
|
|
440
|
+
> [!IMPORTANT]
|
|
441
|
+
> Context is only relevant on the client-side.
|
|
442
|
+
> Context gives you nothing for first paint or the no-JS case.
|
|
443
|
+
>
|
|
444
|
+
|
|
259
445
|
|
|
260
446
|
```ts
|
|
261
|
-
import {
|
|
447
|
+
import { define } from 'microtags'
|
|
448
|
+
import { createContext, provide } from 'microtags/context'
|
|
262
449
|
|
|
263
450
|
const ThemeToken = createContext<string>()
|
|
264
451
|
|
|
265
|
-
|
|
452
|
+
// Parent: owns the theme and publishes it to its descendants. It does
|
|
453
|
+
// not reference the children below. The application composes those in.
|
|
454
|
+
define('theme-provider')
|
|
455
|
+
.setup(ctx => {
|
|
456
|
+
ctx.onCleanup(provide(ctx.host, ThemeToken, 'dark'))
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
// Child: opts in to the theme and reads whichever provider it is
|
|
460
|
+
// composed into. It never receives the value as a prop.
|
|
461
|
+
define('themed-card')
|
|
266
462
|
.withContexts(() => ({
|
|
267
463
|
theme: ThemeToken,
|
|
268
464
|
}))
|
|
269
465
|
.setup(ctx => {
|
|
270
466
|
ctx.effect(() => {
|
|
271
|
-
|
|
467
|
+
ctx.host.dataset.theme = ctx.contexts.theme
|
|
272
468
|
})
|
|
273
469
|
})
|
|
274
470
|
```
|
|
275
471
|
|
|
472
|
+
```html
|
|
473
|
+
<!-- The application writes this markup. It nests <themed-card> inside
|
|
474
|
+
<theme-provider> but never sets the theme itself. -->
|
|
475
|
+
<theme-provider>
|
|
476
|
+
<themed-card>Card content</themed-card>
|
|
477
|
+
</theme-provider>
|
|
478
|
+
```
|
|
479
|
+
|
|
276
480
|
`setup()` is deferred until all required contexts resolve. See
|
|
277
|
-
[microtags/context](#microtagscontext) for the provider API.
|
|
481
|
+
[`microtags/context`](#microtagscontext) for the provider API.
|
|
278
482
|
|
|
279
|
-
### setup and ctx
|
|
483
|
+
### `setup` and `ctx`
|
|
280
484
|
|
|
281
485
|
```ts
|
|
282
486
|
.setup(ctx => {
|
|
283
|
-
// ctx.host
|
|
284
|
-
// ctx.props
|
|
285
|
-
// ctx.refs
|
|
286
|
-
// ctx.contexts
|
|
487
|
+
// ctx.host the HTMLElement itself
|
|
488
|
+
// ctx.props callable signals for each declared prop
|
|
489
|
+
// ctx.refs resolved DOM refs
|
|
490
|
+
// ctx.contexts consumed context values (reactive)
|
|
287
491
|
|
|
288
492
|
// Run a tracked effect; auto-disposed on disconnect.
|
|
289
493
|
ctx.effect(() => {
|
|
@@ -323,46 +527,265 @@ ctx.effect(() => {
|
|
|
323
527
|
})
|
|
324
528
|
```
|
|
325
529
|
|
|
326
|
-
##
|
|
530
|
+
## Templates
|
|
531
|
+
|
|
532
|
+
[`render` and `renderList`](#microtagsrender) both take a `<template>`
|
|
533
|
+
element as their second argument. A
|
|
534
|
+
[`<template>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/template)
|
|
535
|
+
is a native HTML element that holds markup the browser parses but does not
|
|
536
|
+
render. Its contents are inert. Nothing inside paints, scripts do not run,
|
|
537
|
+
images and other resources do not load, and the nodes stay out of the
|
|
538
|
+
document until you clone them.
|
|
539
|
+
|
|
540
|
+
See [./example/ssr.ts, line 89](./example/ssr.ts#L89).
|
|
541
|
+
|
|
542
|
+
You write the markup for an item inside the template, and the
|
|
543
|
+
reconciler stamps out a copy per item by cloning `template.content`. The
|
|
544
|
+
template's first element child is the unit that gets cloned, so give each
|
|
545
|
+
template a single root element.
|
|
546
|
+
|
|
547
|
+
```html
|
|
548
|
+
<item-list>
|
|
549
|
+
<ul></ul>
|
|
550
|
+
<template>
|
|
551
|
+
<li>
|
|
552
|
+
<span class="name"></span>
|
|
553
|
+
<span class="count"></span>
|
|
554
|
+
</li>
|
|
555
|
+
</template>
|
|
556
|
+
</item-list>
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
The component resolves the list and the template as refs, then calls
|
|
560
|
+
`renderList` from inside an effect so the list re-renders whenever the
|
|
561
|
+
data signal changes.
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
import { define } from 'microtags'
|
|
565
|
+
import { signal } from 'alien-signals'
|
|
566
|
+
import { renderList } from 'microtags/render'
|
|
567
|
+
|
|
568
|
+
type Item = { id:string; name:string; count:number }
|
|
569
|
+
|
|
570
|
+
define('item-list')
|
|
571
|
+
.withRefs(r => ({
|
|
572
|
+
list: r.one('ul'),
|
|
573
|
+
row: r.one('template'), // typed as HTMLTemplateElement
|
|
574
|
+
}))
|
|
575
|
+
.setup(ctx => {
|
|
576
|
+
const items = signal<Item[]>([
|
|
577
|
+
{ id: 'a', name: 'Alice', count: 1 },
|
|
578
|
+
{ id: 'b', name: 'Bob', count: 2 },
|
|
579
|
+
])
|
|
580
|
+
|
|
581
|
+
// Re-runs on every items() change; only changed rows are touched.
|
|
582
|
+
ctx.effect(() => {
|
|
583
|
+
renderList(ctx.refs.list, ctx.refs.row, {
|
|
584
|
+
data: items(),
|
|
585
|
+
key: item => item.id,
|
|
586
|
+
update: (el, item) => {
|
|
587
|
+
el.querySelector('.name')!.textContent = item.name
|
|
588
|
+
el.querySelector('.count')!.textContent =
|
|
589
|
+
String(item.count)
|
|
590
|
+
},
|
|
591
|
+
})
|
|
592
|
+
})
|
|
593
|
+
})
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Because the template lives in your HTML instead of a JavaScript string, each
|
|
597
|
+
render clones real DOM nodes rather than re-parsing HTML, and the `update`
|
|
598
|
+
callback function mutates the DOM through properties like `textContent`.
|
|
599
|
+
Nothing concatenates strings into markup, so there is no string-injection
|
|
600
|
+
XSS vector.
|
|
601
|
+
|
|
602
|
+
## Subpath Exports
|
|
603
|
+
|
|
604
|
+
### `microtags/context`
|
|
327
605
|
|
|
328
|
-
|
|
606
|
+
Cross-component context backed by alien-signals. A provider shares a value
|
|
607
|
+
with everything nested inside it; a consumer reads the value from its nearest
|
|
608
|
+
provider ancestor and reacts when it changes.
|
|
329
609
|
|
|
330
|
-
|
|
610
|
+
`provide` seeds a signal the first time it runs and updates that same signal
|
|
611
|
+
on later calls, so re-providing inside an effect pushes changes to consumers.
|
|
331
612
|
|
|
332
613
|
```ts
|
|
333
|
-
import {
|
|
614
|
+
import { define } from 'microtags'
|
|
615
|
+
import { signal } from 'alien-signals'
|
|
616
|
+
import { createContext, provide } from 'microtags/context'
|
|
334
617
|
|
|
335
618
|
const ThemeToken = createContext<string>()
|
|
336
619
|
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
620
|
+
// Provider: owns the theme and shares it with its descendants.
|
|
621
|
+
define('theme-provider')
|
|
622
|
+
.withRefs(r => ({
|
|
623
|
+
toggle: r.one('button'),
|
|
624
|
+
}))
|
|
625
|
+
.setup(ctx => {
|
|
626
|
+
const theme = signal('light')
|
|
627
|
+
|
|
628
|
+
// Share with descendants; remove the entry on disconnect.
|
|
629
|
+
ctx.onCleanup(provide(ctx.host, ThemeToken, theme()))
|
|
340
630
|
|
|
341
|
-
//
|
|
342
|
-
|
|
631
|
+
// Re-provide on change. provide() updates the existing signal,
|
|
632
|
+
// so consumers that read this value re-run.
|
|
633
|
+
ctx.effect(() => provide(ctx.host, ThemeToken, theme()))
|
|
634
|
+
|
|
635
|
+
ctx.on(ctx.refs.toggle, 'click', () => {
|
|
636
|
+
theme(theme() === 'light' ? 'dark' : 'light')
|
|
637
|
+
})
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
// Consumer: reads the nearest provider's theme, reactively.
|
|
641
|
+
define('themed-card')
|
|
642
|
+
.withContexts(() => ({
|
|
643
|
+
theme: ThemeToken,
|
|
644
|
+
}))
|
|
645
|
+
.setup(ctx => {
|
|
646
|
+
// Reading ctx.contexts.theme inside an effect subscribes to it,
|
|
647
|
+
// so this re-runs whenever the provider toggles the value.
|
|
648
|
+
ctx.effect(() => {
|
|
649
|
+
ctx.host.dataset.theme = ctx.contexts.theme
|
|
650
|
+
})
|
|
651
|
+
})
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
```html
|
|
655
|
+
<theme-provider>
|
|
656
|
+
<button>Toggle theme</button>
|
|
657
|
+
<themed-card>Card A</themed-card>
|
|
658
|
+
<themed-card>Card B</themed-card>
|
|
659
|
+
</theme-provider>
|
|
343
660
|
```
|
|
344
661
|
|
|
345
|
-
|
|
662
|
+
Both cards resolve to the same nearest `<theme-provider>`, so one click
|
|
663
|
+
updates both. `resolveContextSignal(element, token)` is the low-level lookup
|
|
664
|
+
those reads use under the hood; reach for it only when you need the provider
|
|
665
|
+
signal imperatively.
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
### `microtags/render`
|
|
670
|
+
|
|
671
|
+
A keyed reconciler for rendering lists and single items from a
|
|
672
|
+
`<template>`, with no virtual DOM and no diffing library.
|
|
346
673
|
|
|
347
|
-
|
|
674
|
+
Microtags' state is reactive. When a signal changes, you want the DOM to follow.
|
|
675
|
+
A way to do that for a list is to rebuild the markup on every change,
|
|
676
|
+
e.g. `container.innerHTML = items.map(toHTML).join('')`.
|
|
677
|
+
|
|
678
|
+
But rebuilding the markup on every change
|
|
679
|
+
**throws away and recreates every node on every update**, losing any DOM
|
|
680
|
+
state, like focus, text selection, scroll position, and the internal state of
|
|
681
|
+
elements like `<video>`, `<details>`, and `<iframe>`. It also re-parses HTML on
|
|
682
|
+
every pass, which creates a vector for string-concatenation XSS.
|
|
683
|
+
|
|
684
|
+
**This library exports a `renderList` and a `render` function**.
|
|
685
|
+
They diff against the live DOM. See
|
|
686
|
+
[example/render-demo.ts](./example/render-demo.ts) for a working example.
|
|
687
|
+
|
|
688
|
+
#### `renderList`
|
|
689
|
+
|
|
690
|
+
Each item is matched to an existing element by its `key` attribute, so nodes
|
|
691
|
+
are reused across renders rather than being recreated. Nodes whose keys
|
|
692
|
+
disappear from the data are removed, surviving nodes are moved into place
|
|
693
|
+
with the fewest DOM operations, and your `update` callback runs only for
|
|
694
|
+
items whose value actually changed (compared by identity). Focus state and other
|
|
695
|
+
DOM state survives a re-render, and changing one row touches only that row.
|
|
348
696
|
|
|
349
697
|
```ts
|
|
350
|
-
|
|
698
|
+
function renderList<T, E extends Element = Element> (
|
|
699
|
+
container:Element,
|
|
700
|
+
template:HTMLTemplateElement,
|
|
701
|
+
options:{
|
|
702
|
+
data:readonly T[];
|
|
703
|
+
key:(item:T, index:number) => string|number;
|
|
704
|
+
update:(el:E, item:T) => void;
|
|
705
|
+
}
|
|
706
|
+
):void
|
|
707
|
+
```
|
|
351
708
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
709
|
+
##### `renderList` Example
|
|
710
|
+
|
|
711
|
+
The markup is the list container plus a `<template>` for one row:
|
|
712
|
+
|
|
713
|
+
```html
|
|
714
|
+
<ul></ul>
|
|
715
|
+
<template>
|
|
716
|
+
<li>
|
|
717
|
+
<span class="name"></span>
|
|
718
|
+
</li>
|
|
719
|
+
</template>
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
Call `renderList` from inside an effect so it re-runs whenever the data
|
|
723
|
+
signal changes:
|
|
724
|
+
|
|
725
|
+
```ts
|
|
726
|
+
ctx.effect(() => {
|
|
727
|
+
renderList(ctx.refs.list, ctx.refs.row, {
|
|
728
|
+
data: items(),
|
|
729
|
+
key: item => item.id,
|
|
730
|
+
update: (el, item) => {
|
|
731
|
+
el.querySelector('.name')!.textContent = item.name
|
|
732
|
+
},
|
|
733
|
+
})
|
|
356
734
|
})
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
#### `render`
|
|
738
|
+
|
|
739
|
+
`render` is the same machinery for a single item. It keys on the template, so
|
|
740
|
+
repeated calls update the same element in place instead of replacing it.
|
|
741
|
+
|
|
742
|
+
```ts
|
|
743
|
+
function render<T, E extends Element = Element> (
|
|
744
|
+
container:Element,
|
|
745
|
+
template:HTMLTemplateElement,
|
|
746
|
+
options?:{
|
|
747
|
+
data?:T;
|
|
748
|
+
update?:(el:E, item:T) => void;
|
|
749
|
+
}
|
|
750
|
+
):void
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
##### `render` Example
|
|
754
|
+
|
|
755
|
+
The markup is a container plus a single-item `<template>`:
|
|
357
756
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
757
|
+
```html
|
|
758
|
+
<div class="item"></div>
|
|
759
|
+
<template>
|
|
760
|
+
<div>
|
|
761
|
+
<span></span>
|
|
762
|
+
</div>
|
|
763
|
+
</template>
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
```ts
|
|
767
|
+
import { render } from 'microtags/render'
|
|
768
|
+
|
|
769
|
+
// Render a single item into the container from the <template>.
|
|
770
|
+
render(container, template, {
|
|
771
|
+
data: item,
|
|
772
|
+
update: (el, item) => {
|
|
773
|
+
el.querySelector('span')!.textContent = item.name
|
|
774
|
+
},
|
|
363
775
|
})
|
|
364
776
|
```
|
|
365
777
|
|
|
778
|
+
For a runnable version, see `example/render-demo.ts`. It keeps an editable
|
|
779
|
+
`<input>` in every row and a single item panel rendered with `render`, then
|
|
780
|
+
re-renders on a timer and reorders on demand to show that focus, caret, and
|
|
781
|
+
typed text survive: keyed nodes are reused and at most moved, never rebuilt.
|
|
782
|
+
|
|
783
|
+
#### Templates
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
366
789
|
## Divergence from nanotags
|
|
367
790
|
|
|
368
791
|
| nanotags (nanostores) | microtags (alien-signals) |
|
|
@@ -373,8 +796,4 @@ renderList(container, template, {
|
|
|
373
796
|
| `ctx.props.$count` (store) | `ctx.props.count` (callable signal) |
|
|
374
797
|
|
|
375
798
|
Props declared with `withProps` are native alien-signals callable signals.
|
|
376
|
-
There is no `$` prefix convention
|
|
377
|
-
|
|
378
|
-
```
|
|
379
|
-
/ed3d-plan-and-execute:execute-implementation-plan /Users/nick/code/microtags/docs/implementation-plans/2026-06-27-two-way-bind-design/ /Users/nick/code/microtags/
|
|
380
|
-
```
|
|
799
|
+
There is no `$` prefix convention; all prop names are plain identifiers.
|