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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # microtags
2
2
 
3
- ![tests](https://github.com/nichoth/microtags/actions/workflows/nodejs.yml/badge.svg)
3
+ [![tests](https://img.shields.io/github/actions/workflow/status/nichoth/microtags/nodejs.yml?style=flat-square)](https://github.com/nichoth/microtags/actions/workflows/nodejs.yml)
4
4
  [![types](https://img.shields.io/npm/types/microtags?style=flat-square)](README.md)
5
5
  [![module](https://img.shields.io/badge/module-ESM%2FCJS-blue?style=flat-square)](README.md)
6
6
  [![semantic versioning](https://img.shields.io/badge/semver-2.0.0-blue?logo=semver&style=flat-square)](https://semver.org/)
@@ -9,9 +9,11 @@
9
9
  [![gzip size](https://img.shields.io/bundlephobia/minzip/microtags?style=flat-square)](https://bundlephobia.com/package/microtags)
10
10
  [![license](https://img.shields.io/badge/license-Big_Time-blue?style=flat-square)](LICENSE)
11
11
 
12
- Web component helper with reactive props, automatic cleanup,
13
- and no Shadow DOM. Reactive via
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
- * [define](#define)
29
- * [withProps](#withprops)
30
- * [withRefs](#withrefs)
31
- * [withContexts](#withcontexts)
32
- * [setup and ctx](#setup-and-ctx)
33
- - [Subpaths](#subpaths)
34
- * [microtags/context](#microtagscontext)
35
- * [microtags/render](#microtagsrender)
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 only (adding behavior).
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 hood
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 { myCounter, countBtn, copyBtn } from './index.js'
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
- <div>
137
- <${myCounter.TAG} start="5">
138
- <div data-ref="display" role="status" class="count"></div>
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
- <${countBtn.TAG} data-ref="dec" label="-" aria-label="Decrement">
142
- </${countBtn.TAG}>
143
- <${copyBtn.TAG} data-ref="copy"></${copyBtn.TAG}>
144
- <${countBtn.TAG} data-ref="inc" label="+" aria-label="Increment">
145
- </${countBtn.TAG}>
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
- </${myCounter.TAG}>
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. Each prop becomes:
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"` / `""` &rarr; `true`, `"false"` &rarr; `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
- define(tagName: string): ComponentBuilder
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(), // attribute -> number (NaN when absent)
189
- label: p.string(), // attribute -> string ('' when absent)
190
- active: p.boolean(), // attribute -> boolean (true if present)
191
- data: p.json<MyType>(), // attribute -> JSON.parse or undefined
192
- schema: p.schema(zodSchema), // Standard Schema (Zod, Valibot, ArkType …)
193
- value: p.prop(0), // property-only, not observed as an attribute
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
- ### withRefs
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"] — throws if missing
209
- items: r.all(), // resolves all [data-ref="items"] — empty array if none
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
- Call `r.one()` / `r.all()` with no argument to match `[data-ref="<key>"]`,
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
- Refs are resolved from within the element during `connectedCallback`.
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
- ### withContexts
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 { createContext } from 'microtags/context'
447
+ import { define } from 'microtags'
448
+ import { createContext, provide } from 'microtags/context'
262
449
 
263
450
  const ThemeToken = createContext<string>()
264
451
 
265
- define('my-child')
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
- console.log('theme is', ctx.contexts.theme)
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 — the HTMLElement itself
284
- // ctx.props — callable signals for each declared prop
285
- // ctx.refs — resolved DOM refs
286
- // ctx.contexts — consumed context values (reactive)
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
- ## Subpaths
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
- ### microtags/context
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
- Cross-component context backed by alien-signals.
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 { createContext, provide, resolveContextSignal } from 'microtags/context'
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
- // In a provider component's setup():
338
- const cleanup = provide(ctx.host, ThemeToken, 'dark')
339
- ctx.onCleanup(cleanup)
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
- // In a consumer component via withContexts:
342
- ctx.contexts.theme // 'dark' (reactive — re-runs effects when value changes)
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
- ### microtags/render
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
- DOM diffing helpers for rendering lists and single items without a virtual DOM.
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
- import { render, renderList } from 'microtags/render'
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
- // Render a single item into a container from a <template>.
353
- render(container, template, {
354
- data: item,
355
- update: (el, item) => { el.querySelector('span')!.textContent = item.name },
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
- // Render a keyed list with minimal DOM mutations.
359
- renderList(container, template, {
360
- data: items,
361
- key: item => item.id,
362
- update: (el, item) => { /* update el from item */ },
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 — all prop names are plain identifiers.
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.