microtags 0.0.8 → 0.0.11

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
@@ -9,11 +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, and no
13
- [Shadow DOM](https://gomakethings.com/the-shadow-dom-is-an-antipattern/).
12
+ Web component helper with reactive props, automatic cleanup, and
13
+ [no Shadow DOM](https://gomakethings.com/the-shadow-dom-is-an-antipattern/).
14
14
  Reactive via [alien-signals](https://github.com/stackblitz/alien-signals).
15
15
 
16
- **Size: 3.41 KB** with all dependencies, minified and brotlied
16
+ **Size: <!-- size -->3.48 KB<!-- /size -->** with all dependencies, minified and brotlied
17
17
 
18
18
  Inspired by [nanotags](https://nanotags.psdcoder.dev/). Reactive props use
19
19
  **alien-signals** instead of nanostores atoms.
@@ -25,21 +25,31 @@ Inspired by [nanotags](https://nanotags.psdcoder.dev/). Reactive props use
25
25
  - [Install](#install)
26
26
  * [Import map](#import-map)
27
27
  * [Pre-bundled](#pre-bundled)
28
- - [Example](#example)
28
+ - [Get Started](#get-started)
29
+ * [1. Call `.define`](#1-call-define)
30
+ * [2. Call the `.with*` Methods](#2-call-the-with-methods)
31
+ * [3. Call `.setup`](#3-call-setup)
32
+ - [Full Example](#full-example)
33
+ - [Validation](#validation)
34
+ * [Prop validation](#prop-validation)
35
+ + [`p.schema` vs Standard Schema](#pschema-vs-standard-schema)
29
36
  * [Client-side validation](#client-side-validation)
30
37
  - [Serverside Example](#serverside-example)
31
38
  * [`.TAG`](#tag)
32
- * [`.refs`](#refs)
33
39
  - [API](#api)
34
- * [Props](#props)
35
- + [`withProps`](#withprops)
36
- * [Refs](#refs)
37
- + [`withRefs`](#withrefs)
38
- + [Element types](#element-types)
39
40
  * [`define`](#define)
40
- * [`withContexts`](#withcontexts)
41
- * [`setup` and `ctx`](#setup-and-ctx)
42
- + [peek and untracked](#peek-and-untracked)
41
+ + [`ComponentBuilder`](#componentbuilder)
42
+ * [`builder.withProps`](#builderwithprops)
43
+ + [`p.json`](#pjson)
44
+ + [`p.schema`](#pschema)
45
+ * [`builder.withRefs(fn)`](#builderwithrefsfn)
46
+ * [`builder.withContexts`](#builderwithcontexts)
47
+ + [`.withContexts` Example](#withcontexts-example)
48
+ * [`builder.setup(ctx)`](#buildersetupctx)
49
+ + [`ctx`](#ctx)
50
+ - [Missing refs](#missing-refs)
51
+ * [Checking refs in tests](#checking-refs-in-tests)
52
+ - [Typed Events](#typed-events)
43
53
  - [Templates](#templates)
44
54
  - [Subpath Exports](#subpath-exports)
45
55
  * [`microtags/context`](#microtagscontext)
@@ -48,14 +58,14 @@ Inspired by [nanotags](https://nanotags.psdcoder.dev/). Reactive props use
48
58
  + [`render`](#render)
49
59
  * [`microtags/util`](#microtagsutil)
50
60
  + [Coercion](#coercion)
51
- + [`runCleanups`](#runcleanups)
61
+ + [`runAll`](#runall)
52
62
  + [`toAttributes`](#toattributes)
53
- - [Divergence from `nanotags`](#divergence-from-nanotags)
63
+ - [Differences from `nanotags`](#differences-from-nanotags)
54
64
  * [Reactivity primitive](#reactivity-primitive)
55
65
  * [Effects + Subscriptions](#effects--subscriptions)
56
66
  * [Refs: `r.all` instead of `r.many`](#refs-rall-instead-of-rmany)
57
67
  * [Context API](#context-api)
58
- * [Props](#props-1)
68
+ * [Props](#props)
59
69
  * [Not ported](#not-ported)
60
70
  * [Additions / New Features](#additions--new-features)
61
71
 
@@ -63,9 +73,7 @@ Inspired by [nanotags](https://nanotags.psdcoder.dev/). Reactive props use
63
73
 
64
74
  </details>
65
75
 
66
- ----------------------------------------------------------------------
67
76
  ## Install
68
- ----------------------------------------------------------------------
69
77
 
70
78
  ```sh
71
79
  npm i -S microtags
@@ -96,10 +104,8 @@ Subpaths follow the same URL pattern: `microtags/context`, `microtags/render`.
96
104
 
97
105
  ### Pre-bundled
98
106
 
99
- For raw-file CDNs (jsDelivr, unpkg) or local files, where dependencies are
100
- not resolved for you, use the pre-bundled `microtags/min` build. It is a
101
- single self-contained file with `alien-signals` inlined, so no other
102
- mappings are needed:
107
+ This package exposes `microtags/min`, which is a single self-contained file
108
+ with `alien-signals` inlined.
103
109
 
104
110
  ```html
105
111
  <script type="importmap">
@@ -111,23 +117,89 @@ mappings are needed:
111
117
  </script>
112
118
  ```
113
119
 
120
+ ## Get Started
114
121
 
115
- ----------------------------------------------------------------------
116
- ## Example
117
- ----------------------------------------------------------------------
122
+ ### 1. Call `.define`
118
123
 
119
- This example depends on having the full HTML already in the DOM. If you want
120
- to do client-side-rendered components, that is possible. You would update the
121
- DOM inside `.setup`.
124
+ ```ts
125
+ import { define } from 'microtags'
122
126
 
123
- Since the full HTML exists already, defining your components in the
124
- client-side JS is about "hydrating" the page (adding behavior).
127
+ const myCounter = define('my-counter')
128
+ ```
125
129
 
126
130
  ---
127
131
 
128
- `withProps`, `withRefs`, and `withContexts` are optional and can appear in
129
- any order. `.setup` ends the chain: it calls `customElements.define` under the
130
- hood and returns a typed constructor.
132
+ ### 2. Call the `.with*` Methods
133
+
134
+ Methods `withProps`, `withRefs`, and `withContexts` are optional and can appear
135
+ in any order. `.setup` ends the chain. It calls `customElements.define` under
136
+ the hood and returns a typed constructor.
137
+
138
+ ```ts
139
+ myCounter
140
+ .withProps(p => ({
141
+ start: p.number(), // the starting count (an attribute)
142
+ }))
143
+ .withRefs(r => ({ // keys in this object are the `data-ref` names in HTML
144
+ display: r.one<HTMLDivElement>(), // typed as HTMLDivElement,
145
+ inc: r.one(), // depends on a child in HTML with `data-ref="inc"`
146
+ dec: r.one(), // depends on a child in HTML with `data-ref="dec"`
147
+ copy: r.one(), // the <copy-btn> element, via `data-ref="copy"`
148
+ }))
149
+ ```
150
+
151
+ ### 3. Call `.setup`
152
+
153
+ Method `.setup` takes a function that gets called with `context` object.
154
+
155
+ ```ts
156
+ myCounter
157
+ .setup(ctx => {
158
+ const count = signal(ctx.props.start())
159
+ // `ctx.refs` are expected to be HTML elements with a matching `data-ref`
160
+ const display = ctx.refs.display
161
+
162
+ // whenever `count` changes
163
+ ctx.effect(() => {
164
+ display.textContent = `Count: ${count()}`
165
+ display.style.color = count() < 0 ? 'red' : 'inherit'
166
+ })
167
+
168
+ // Reflect the live count onto the copy button's `value` attribute.
169
+ // Because `value` is attribute-backed, this flows into <copy-btn>'s
170
+ // own signal, so it always copies the current count.
171
+ ctx.effect(() => {
172
+ const copy = ctx.refs.copy
173
+ copy.setAttribute('value', String(count()))
174
+ })
175
+
176
+ /**
177
+ * Listen for clicks.
178
+ */
179
+ ctx.on(ctx.refs.inc, 'click', () => {
180
+ count(count() + 1)
181
+ })
182
+
183
+ ctx.on(ctx.refs.dec, 'click', () => {
184
+ count(count() - 1)
185
+ })
186
+ })
187
+ ```
188
+
189
+ ## Full Example
190
+
191
+ This example expects the full HTML to already be in the DOM.
192
+ Defining the components is about "hydrating" the page, or adding behavior.
193
+
194
+ If you want to client-side render the components, that is possible too.
195
+ You can update the DOM inside `.setup`.
196
+
197
+ >
198
+ > [!NOTE]
199
+ > The server should know how to create the markup that components depend
200
+ > on here. See [the serverside example](#serverside-example).
201
+ >
202
+
131
203
 
132
204
  ```ts
133
205
  import { define } from 'microtags'
@@ -135,7 +207,7 @@ import { signal } from 'alien-signals'
135
207
 
136
208
  /**
137
209
  * - attribute-backed prop (`start`)
138
- * - data-ref elements (the <count-button> and <copy-btn> children)
210
+ * - data-ref elements -- each needs a data-ref attribute, eg data-ref="inc"
139
211
  * - ctx.effect, ctx.on
140
212
  */
141
213
  export const myCounter = define('my-counter')
@@ -184,25 +256,102 @@ export const myCounter = define('my-counter')
184
256
  </my-counter>
185
257
  ```
186
258
 
187
- ### Client-side validation
188
-
189
- See [example/subscribe-form.ts](./example/subscribe-form.ts).
259
+ ## Validation
190
260
 
191
- There are two separate things people call "validation", and `microtags`
192
- handles them in different places:
261
+ There are two separate things people call "validation":
193
262
 
194
- 1. **Prop validation** checks the attributes passed *into* a component.
263
+ 1. **Prop validation** checks the attributes passed into a component.
195
264
  Pass a [Standard Schema](https://standardschema.dev) (Zod, Valibot,
196
265
  ArkType) to [`p.schema`](#withprops); it coerces the attribute value
197
- and falls back to `undefined` when the value doesn't match.
198
- 2. **Form input validation** checks live user input in a form, for example a
199
- value that changes on every keystroke and needs to report *why* it is invalid.
266
+ and falls back to `undefined` when the value doesn't coerce.
267
+ 2. **Form input validation** -- client-side validation -- checks live user input
268
+ in a form, for example a value that changes on every keystroke.
200
269
  There is no dedicated API for this. Validate the schema directly
201
- inside `.setup()` and hold the result in a signal:
270
+ inside `.setup()` and hold the result as a signal.
271
+
272
+ ### Prop validation
202
273
 
203
- The example below is the second kind -- form input (client-side) validation.
274
+ Pass a [Standard Schema](https://standardschema.dev) validator to `p.schema`.
275
+ The validator runs against the raw attribute and its output becomes the
276
+ signal's value; anything it rejects falls back to `undefined`.
204
277
 
205
278
  ```ts
279
+ import { z } from 'zod'
280
+ import { define } from 'microtags'
281
+
282
+ // Any Standard Schema validator works (Zod, Valibot, ArkType). microtags
283
+ // only sees the shared `~standard` interface, so it has no validation
284
+ // library of its own and no dependency on the one you pick.
285
+ const Variant = z.enum(['info', 'success', 'warning', 'danger'])
286
+
287
+ define('status-badge')
288
+ .withProps(p => ({
289
+ // Validates the raw `variant` attribute. A value outside the
290
+ // four options falls back to `undefined`.
291
+ variant: p.schema(Variant),
292
+ }))
293
+ .setup(ctx => {
294
+ ctx.effect(() => {
295
+ const variant = ctx.props.variant() ?? 'info'
296
+ ctx.host.className = `badge badge-${variant}`
297
+ })
298
+ })
299
+ ```
300
+
301
+ ```html
302
+ <status-badge variant="success">Saved</status-badge>
303
+
304
+ <!-- "purple" is not in the enum, so ctx.props.variant() is undefined -->
305
+ <status-badge variant="purple">Unknown</status-badge>
306
+ ```
307
+
308
+ #### `p.schema` vs Standard Schema
309
+
310
+ [Standard Schema](https://standardschema.dev) is a small shared interface
311
+ that Zod, Valibot and ArkType all implement. `p.schema` validates through that
312
+ interface, so microtags ships no validator of its own.
313
+
314
+ A schema prop is attribute-backed and observed, exactly like `p.string()` or
315
+ `p.number()`: editing the attribute re-runs the validator and pushes the result
316
+ to `ctx.props`.
317
+
318
+ The validator's input is the raw attribute string, or `null` when the attribute
319
+ is absent. Because the input is always a string, schemas for non-string values
320
+ must coerce. Use `z.coerce.number()`, not `z.number()`:
321
+
322
+ ```ts
323
+ .withProps(p => ({
324
+ // "5" -> 5; "0" and the absent attribute both fall back to undefined
325
+ quantity: p.schema(z.coerce.number().int().min(1)),
326
+ }))
327
+ ```
328
+
329
+ **On success the signal holds the validator's output value**; on failure it
330
+ falls back to `undefined`. The inferred signal type is the schema's output,
331
+ so guard for the `undefined` case, or fold a default into the schema so it
332
+ never fails:
333
+
334
+
335
+ ```ts
336
+ {
337
+ // always 'info' | 'success' | 'warning' | 'danger', never undefined
338
+ variant: p.schema(Variant.catch('info')),
339
+ }
340
+ ```
341
+
342
+ Only synchronous schemas are supported; an async schema throws when the
343
+ attribute is validated.
344
+
345
+ -------------------------------------------------------
346
+
347
+ ### Client-side validation
348
+
349
+ There is no special API here. Validate input inside `.setup`,
350
+
351
+ See [example/subscribe-form.ts](./example/subscribe-form.ts).
352
+
353
+ ```ts
354
+ // form input (client-side) validation
206
355
  import { signal, startBatch, endBatch } from 'alien-signals'
207
356
  import { z } from 'zod'
208
357
  import { define } from 'microtags'
@@ -219,18 +368,21 @@ define('subscribe-form')
219
368
  }))
220
369
  .setup(ctx => {
221
370
  const value = signal('')
222
- const touched = signal(false)
371
+ const touched = signal<boolean>(false)
223
372
 
224
373
  // capture the live value (two-way; clears the field on reset)
225
374
  ctx.bind(value, ctx.refs.input)
375
+
226
376
  // only reveal errors after the first blur
227
377
  ctx.on(ctx.refs.input, 'blur', () => touched(true))
228
378
 
379
+ // ctx.effect is alien-signals/effect with automatic/correct disposal
229
380
  ctx.effect(() => {
381
+ // subscribe to `value` and `touched`
230
382
  const result = Email.safeParse(value())
231
- const message = result.success ?
383
+ const message = (result.success ?
232
384
  '' :
233
- result.error.issues[0].message
385
+ result.error.issues[0].message)
234
386
  const showError = touched() && !result.success
235
387
 
236
388
  ctx.refs.submit.disabled = !result.success
@@ -239,6 +391,7 @@ define('subscribe-form')
239
391
  })
240
392
 
241
393
  ctx.on(ctx.refs.form, 'submit', ev => {
394
+ // subscribe to DOM events
242
395
  ev.preventDefault()
243
396
  if (!Email.safeParse(value()).success) return
244
397
  ctx.refs.status.textContent = 'Thanks for subscribing!'
@@ -250,14 +403,16 @@ define('subscribe-form')
250
403
  })
251
404
  ```
252
405
 
253
- The error stays hidden until the field is blurred once, then updates live;
254
- the submit button is gated on validity. See `example/subscribe-form.ts`
255
- for the full working component.
406
+ The error stays hidden until the field is blurred once, then updates live,
407
+ and the submit button is disabled until valid. See `example/subscribe-form.ts`.
408
+
409
+
410
+ ---------------------------------------------------------------
256
411
 
257
412
 
258
- ----------------------------------------------------------------------
259
413
  ## Serverside Example
260
- ----------------------------------------------------------------------
414
+
415
+ See [./example/ssr.ts](./example/ssr.ts).
261
416
 
262
417
  ### `.TAG`
263
418
 
@@ -265,11 +420,11 @@ Each tag name that you pass to `define` is exposed as a property `.TAG`
265
420
  on the return value. It is possible to import your web components in Node,
266
421
  even though Node does not have browser APIs.
267
422
 
268
- ### `.refs`
423
+ The `data-ref` attribute is a fallback. It's used when you don't pass a
424
+ selector to `r.one()`. If you pass explicit CSS selectors to `withRefs`,
425
+ e.g. `{ copy: r.one('button') }`, then the HTML does not require a `data-ref`
426
+ attribute.
269
427
 
270
- All refs are defined per-component, and the ref names are exposed at `.refs`,
271
- so you can import your web components on the server and read the ref names
272
- as strings.
273
428
 
274
429
  ```ts
275
430
  import { MyCounter, CountBtn, CopyBtn } from './index.js'
@@ -311,11 +466,39 @@ export function render ():string {
311
466
  }
312
467
  ```
313
468
 
314
- ----------------------------------------------------------------------
469
+
470
+ ---------------------------------------------------------------------
471
+
472
+
315
473
  ## API
316
- ----------------------------------------------------------------------
317
474
 
318
- ### Props
475
+ ### `define`
476
+
477
+ ```js
478
+ function define (tagName:string):ComponentBuilder
479
+ ```
480
+
481
+ Function `define(tagName)` returns a [`ComponentBuilder`](#componentbuilder).
482
+
483
+
484
+ #### `ComponentBuilder`
485
+
486
+ The object returned by [`define`](#define). Includes methods:
487
+
488
+ | Method | Returns | Description |
489
+ |--------|---------|-------------|
490
+ | `.TAG` | `string` | The tag name passed to `define`. See [`.TAG`](#tag). |
491
+ | `.withProps(fn)` | `ComponentBuilder` | Declare reactive attributes. See [Props](#props). |
492
+ | `.withRefs(fn)` | `ComponentBuilder` | Declare DOM refs resolved once the element connects. See [Refs](#refs-1). |
493
+ | `.withContexts(fn)` | `ComponentBuilder` | Consume context values published by an ancestor. See [`withContexts`](#withcontexts). |
494
+ | `.setup(fn)` | `MicrotagElementClass` | Registers the custom element and returns its class. See [`setup`](#setup) below. |
495
+
496
+ Methods `withProps`, `withRefs`, and `withContexts` each return a new builder,
497
+ so they chain in any order; `setup` ends the chain by registering the element.
498
+
499
+ ---------------------------------------------------------
500
+
501
+ ### `builder.withProps`
319
502
 
320
503
  Declare reactive attributes via `withProps`. Each prop becomes:
321
504
 
@@ -331,8 +514,6 @@ Declare reactive attributes via `withProps`. Each prop becomes:
331
514
  | `p.boolean()` | `"true"` / `""` &rarr; `true`, `"false"` &rarr; `false` | `false` |
332
515
  | `p.oneOf(opts)` | Picklist enum, throws on invalid | throws |
333
516
 
334
- #### `withProps`
335
-
336
517
  ```ts
337
518
  .withProps(p => ({
338
519
  count: p.number(), // attribute -> number (NaN when absent)
@@ -344,7 +525,15 @@ Declare reactive attributes via `withProps`. Each prop becomes:
344
525
  }))
345
526
  ```
346
527
 
347
- ##### `p.json`
528
+ Inside `.setup()`, every prop is a callable signal:
529
+
530
+ ```ts
531
+ ctx.props.count() // read
532
+ ctx.props.count(42) // write
533
+ ```
534
+
535
+
536
+ #### `p.json`
348
537
 
349
538
  `p.json<T>()` -- a prop whose attribute value is parsed with
350
539
  `JSON.parse`. The type parameter `T` annotates the parsed result; it is a
@@ -367,6 +556,7 @@ observed, so editing the attribute re-parses and pushes the new value to
367
556
  attribute is absent, and when the value is not valid JSON. Parse errors are
368
557
  swallowed rather than thrown.
369
558
 
559
+
370
560
  ```ts
371
561
  // check for `undefined` case
372
562
  ctx.effect(() => {
@@ -376,32 +566,46 @@ ctx.effect(() => {
376
566
  })
377
567
  ```
378
568
 
569
+ #### `p.schema`
570
+
379
571
  For input you want validated rather than only parsed, pass a Standard Schema
380
572
  to [`p.schema`](#withprops) instead.
381
573
 
382
- Inside `setup()`, every prop is a callable signal:
574
+
575
+ ---------------------------------------------------------
576
+
577
+
578
+ ### `builder.withRefs(fn)`
579
+
580
+ Takes a function that gets called with a helpful query selector, `r`.
581
+
582
+ Call `r.one()` / `r.all()` with no argument to match `[data-ref="<key>"]`,
583
+ where `key` is the key name you passed in. Or pass a CSS selector to query by
584
+ it instead. Queries are resolved relative to the host element, during
585
+ [the `connectedCallback`](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks).
586
+
587
+ All refs returned from this callback are typed as much as possible, and exposed
588
+ on the `context` object passed to the `.setup` function.
589
+
383
590
 
384
591
  ```ts
385
- ctx.props.count() // read
386
- ctx.props.count(42) // write
592
+ class ComponentBuilder {
593
+ withRefs<R extends Record<string, RefDef>> (
594
+ fn:(r:RefsDSL) => R
595
+ ):ComponentBuilder<Props, R, CtxDefs>
596
+ }
387
597
  ```
388
598
 
389
- ---
390
599
 
391
- ### Refs
600
+ Takes a function that gets called with a [`RefsDSL`](./src/builders.ts#L54)
601
+ object. It should return an object of child elements that you want to keep
602
+ references to.
392
603
 
393
604
  The `data-ref` attribute is a fallback. It's used when you don't pass a
394
605
  selector to `r.one()`. If you pass explicit CSS selectors to `withRefs`,
395
606
  e.g. `{ copy: r.one('button') }`, then the HTML does not require a `data-ref`
396
607
  attribute.
397
608
 
398
- #### `withRefs`
399
-
400
- Call `r.one()` / `r.all()` with no argument to match `[data-ref="<key>"]`,
401
- where `key` is the key name you pass in, or pass a CSS selector to query by
402
- it instead. Queries are resolved relative to the host element, during
403
- [the `connectedCallback`](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks).
404
-
405
609
 
406
610
  ```ts
407
611
  .withRefs(r => ({
@@ -419,67 +623,41 @@ If there are no `data-ref` attribtues in the HTML, use CSS query selectors.
419
623
  }))
420
624
  ```
421
625
 
422
- See [#refs in serverside-rendering](#refs). Each component created with
423
- `micortags` exposes a `.refs` property that is an object map
424
- of ref names, eg `MyComponent.refs.copyButton`.
425
626
 
426
- #### Element types
627
+ ---------------------------------------------------------------------
427
628
 
428
- An un-annotated `r.one()` is typed as `HTMLElement`, so `.style`,
429
- `.textContent`, and `.dataset` work without a cast. There are two ways to
430
- get a more specific element type:
431
629
 
432
- ```ts
433
- .withRefs(r => ({
434
- // Generic argument: sets the type, keeps the [data-ref] selector.
435
- display: r.one<HTMLDivElement>(),
436
-
437
- // Tag string: infers the type AND queries by that tag, exactly like
438
- // document.querySelector('button'). It does NOT keep [data-ref].
439
- save: r.one('button'), // SingleRefDef<HTMLButtonElement>
440
- items: r.all('li'), // MultipleRefDef<HTMLLIElement>
441
- button: r.one(), // HTMLElement (un-annotated default)
442
- }))
443
- ```
630
+ ### `builder.withContexts`
444
631
 
445
- These flow straight through to `ctx.refs`:
632
+ Context is for the case where a parent component defines some state, e.g. a
633
+ `theme`, and the top-level (application) knows nothing about the theme, but the
634
+ application *does* determine what children the parent renders
635
+ (the app passes in children), and the children need to know the theme.
446
636
 
447
637
  ```ts
448
- ctx.refs.display // HTMLDivElement
449
- ctx.refs.save // HTMLButtonElement
450
- ctx.refs.items // HTMLLIElement[]
451
- ctx.refs.button // HTMLElement (the un-annotated default)
638
+ class ComponentBuilder {
639
+ withContexts<C extends Record<string, ContextToken<unknown>>> (
640
+ fn:(c:ContextDSL) => C
641
+ ):ComponentBuilder<Props, RefDefs, C>
642
+ }
452
643
  ```
453
644
 
454
- Non-HTML refs (SVG / MathML) are out of scope: the type parameter is
455
- constrained to `HTMLElement`, so cast at the use site if you need one
456
- (e.g. `ctx.refs.icon as unknown as SVGSVGElement`).
457
-
645
+ #### `.withContexts` Example
458
646
 
459
- ### `define`
460
-
461
- ```ts
462
- import { define } from 'microtags'
463
-
464
- define(tagName:string):ComponentBuilder
647
+ ```html
648
+ <!-- The application composes this markup. -->
649
+ <theme-provider>
650
+ <some-child></some-child>
651
+ <another-child></another-child>
652
+ </theme-provider>
465
653
  ```
466
654
 
467
- Returns a fluent builder. Call `.setup()` to register the custom element.
468
- All builder methods are chainable and fully type-inferred.
469
-
470
- ### `withContexts`
471
-
472
- Context is for the case where a parent component defines some state, e.g. a
473
- `theme`, and the top-level (application) knows nothing about the theme. But the
474
- top level application does determine what children the parent renders
475
- (it passes in children), and the children need to know the theme.
476
-
477
655
  In that case, there is no way for the "parent" component to pass the theme to
478
- the children, and the root level (application) doesn't know what the theme is.
656
+ the children. The root level (application) doesn't know what the theme is.
479
657
 
480
- Context gives us a "pull" based way to model this. The context-provider
481
- publishes the value, and any descendants opt in (or pull values) with
482
- `withContexts`, and the unknown-consumer and late-arrival problems are handled.
658
+ Context gives us a way to model this. The context-provider
659
+ publishes the value, and any descendants opt in with
660
+ `withContexts`.
483
661
 
484
662
  >
485
663
  > [!IMPORTANT]
@@ -487,7 +665,6 @@ publishes the value, and any descendants opt in (or pull values) with
487
665
  > Context gives you nothing for first paint or the no-JS case.
488
666
  >
489
667
 
490
-
491
668
  ```ts
492
669
  import { define } from 'microtags'
493
670
  import { createContext, provide } from 'microtags/context'
@@ -498,7 +675,8 @@ const ThemeToken = createContext<string>()
498
675
  // not reference the children below. The application composes those in.
499
676
  define('theme-provider')
500
677
  .setup(ctx => {
501
- ctx.onCleanup(provide(ctx.host, ThemeToken, 'dark'))
678
+ const rmContext = provide(ctx.host, ThemeToken, 'dark')
679
+ ctx.onCleanup(rmContext)
502
680
  })
503
681
 
504
682
  // Child: opts in to the theme and reads whichever provider it is
@@ -515,65 +693,180 @@ define('themed-card')
515
693
  ```
516
694
 
517
695
  ```html
518
- <!-- The application writes this markup. It nests <themed-card> inside
519
- <theme-provider> but never sets the theme itself. -->
520
696
  <theme-provider>
521
697
  <themed-card>Card content</themed-card>
522
698
  </theme-provider>
523
699
  ```
524
700
 
525
- `setup()` is deferred until all required contexts resolve. See
526
- [`microtags/context`](#microtagscontext) for the provider API.
527
701
 
528
- ### `setup` and `ctx`
702
+ ---------------------------------------------------------
703
+
704
+
705
+ ### `builder.setup(ctx)`
706
+
707
+ Registers the custom element via `customElements.define` and returns the
708
+ element class. The returned class exposes `.TAG` and `.refs` as static
709
+ members, for use in [serverside rendering](#serverside-example).
710
+
711
+ ```ts
712
+ class ComponentBuilder {
713
+ setup (
714
+ fn:(ctx:SetupContext<
715
+ Props,
716
+ RefsMap<RefDefs>,
717
+ ContextsMap<CtxDefs>
718
+ >) => void
719
+ ):MicrotagElementClass<RefDefs> {
720
+ }
721
+ ```
722
+
723
+ The callback function runs once per element instance, and is called with the
724
+ following `context` object.
725
+
726
+
727
+ #### `ctx`
728
+
729
+ The argument passed to the callback given to `setup`.
529
730
 
530
731
  ```ts
531
- .setup(ctx => {
532
- // ctx.host the HTMLElement itself
533
- // ctx.props callable signals for each declared prop
534
- // ctx.refs resolved DOM refs
535
- // ctx.contexts consumed context values (reactive)
732
+ ctx:SetupContext<Props, RefsMap<RefDefs>, ContextsMap<CtxDefs>>
733
+ ```
536
734
 
537
- // Run a tracked effect; auto-disposed on disconnect.
538
- ctx.effect(() => {
539
- document.title = ctx.props.label()
540
- })
735
+ ##### `ctx` Methods
736
+
737
+ | Method | Returns | Description |
738
+ |--------|---------|-------------|
739
+ | `.peek(signal)` | `T` | Read a signal's current value without subscribing, even inside `ctx.effect`. |
740
+ | `.on(target, type, handler, options?)` | `void` | Add a DOM event listener, auto-removed on disconnect. |
741
+ | `.emit(event)` / `.emit(name, detail?, options?)` | `boolean` | Dispatch a pre-built `Event` as-is, or construct and dispatch a bubbling `CustomEvent` from a name. Returns `dispatchEvent`'s result: `false` if a cancelable event had `preventDefault()` called. |
742
+ | `.effect(fn)` | `void` | Run an alien-signals effect that auto-tracks the signals it reads. Auto-disposed on disconnect. |
743
+ | `.bind(signal, element, options?)` | `void` | Bind a signal to a DOM element property, auto-cleaned on disconnect. |
744
+ | `.onCleanup(fn)` | `void` | Register custom teardown to run on disconnect. |
745
+ | `.consume(token)` | `T \| undefined` | Read an optional context value published by an ancestor. See [`withContexts`](#withcontexts). |
746
+
747
+ See [Typed Events](#typed-events) for more about `.on` and `.emit`.
541
748
 
542
- // Bind a DOM property to a signal expression; auto-cleaned.
543
- ctx.bind(() => String(ctx.props.count()), el, { prop: 'textContent' })
749
+ ##### `ctx.bind`
544
750
 
545
- // Add an event listener; auto-removed on disconnect.
546
- ctx.on(el, 'click', handler)
751
+ The `.bind` method treats the signal as the source of truth. The element
752
+ property is set from the signal.
753
+ With no `options`, the control's element property and write-back event are
754
+ auto-detected from its type:
547
755
 
548
- // Dispatch an event from the host. Pass a name (with optional
549
- // detail/options) for a bubbling CustomEvent, or a pre-built
550
- // Event. Returns dispatchEvent's result.
551
- ctx.emit('change', { value: 42 })
756
+ ```ts
757
+ {
758
+ bind<T> (
759
+ source:Signal<T>,
760
+ control:Element,
761
+ opts?:BindOptions
762
+ )
763
+ }
764
+ ```
765
+
766
+ | Control | `prop` | write-back `event` |
767
+ |---------|--------|---------------------|
768
+ | `<input type="checkbox">` | `checked` | `change` |
769
+ | `<input type="number">` / `type="range"` | `valueAsNumber` | `input` |
770
+ | Other `<input>` types / `<textarea>` | `value` | `input` |
771
+ | Anything else, e.g. `<select>` | `value` | `change` |
772
+
773
+ Passing `options` overrides the auto-detected `prop`, and replaces the
774
+ write-back `event` -- omit `options.event` for a one-way binding. A read-only
775
+ getter (`() => T`) is also accepted as the `signal` argument, for binding a
776
+ derived value one-way (pass it with `options` and omit `event`).
777
+
778
+ ```ts
779
+ // two-way, auto-detected (checkbox -> `checked` / `change`)
780
+ ctx.bind(checked, ctx.refs.toggle)
781
+
782
+ // one-way: push a derived value into a read-only display
783
+ ctx.bind(() => `${count()} items`, ctx.refs.label, { prop: 'textContent' })
784
+ ```
552
785
 
553
- // Read a signal without creating a subscription.
554
- const snap = ctx.peek(ctx.props.count)
786
+ ---
555
787
 
556
- // Register arbitrary teardown logic.
557
- ctx.onCleanup(() => { /* ... */ })
558
788
 
559
- // Consume a context imperatively (returns value or undefined).
560
- const theme = ctx.consume(ThemeToken)
789
+ ## Missing refs
790
+
791
+ Any calls to `r.one` in `.withRefs` will throw if there is not a matching
792
+ child element.
793
+
794
+ ```
795
+ Error: MY-COMPONENT: missing refs: button, save
796
+ ```
797
+
798
+ The tag name is upper case (it comes from `host.tagName`), and the
799
+ message lists every required ref that was not found.
800
+
801
+ Call to `r.all()` are **never reported**. A missing match resolves to an
802
+ empty array, not an error.
803
+
804
+ This check happens right before the custom element runs your `.setup` function.
805
+
806
+ For a component with no contexts, refs are resolved at the moment the element
807
+ connects to the DOM. For a component that declares
808
+ [`withContexts`](#withcontexts), ref collection is deferred along
809
+ with `setup` until every required context resolves.
810
+
811
+
812
+ ### Checking refs in tests
813
+
814
+ A missing `r.one` ref throws while the component connects. When the browser
815
+ runs a lifecycle callback (which is what `appendChild` triggers), it reports
816
+ the exception to the global `error` event rather than propagating it to the
817
+ caller, so a try / catch around `appendChild` sees nothing.
818
+
819
+ You need to listen for the global `error` event. When every
820
+ `data-ref` the component declares is present, nothing is reported.
821
+
822
+ ```ts
823
+ import { test } from '@substrate-system/tapzero'
824
+ import './my-counter.js' // the component from the example above
825
+
826
+ test('all refs resolve', t => {
827
+ let err:Error|null = null
828
+ const onError = (ev:ErrorEvent) => { err = ev.error }
829
+ window.addEventListener('error', onError)
830
+
831
+ const el = document.createElement('my-counter')
832
+ el.innerHTML = `
833
+ <div data-ref="display"></div>
834
+ <button data-ref="dec">-</button>
835
+ <button data-ref="inc">+</button>`
836
+ document.body.appendChild(el)
837
+
838
+ t.ok(err === null, 'no missing refs')
839
+
840
+ window.removeEventListener('error', onError)
841
+ document.body.removeChild(el)
561
842
  })
562
843
  ```
563
844
 
564
- #### peek and untracked
565
845
 
566
- When you need a non-subscribing read outside of `ctx.peek`, both helpers are
567
- exported from the main entry:
846
+ ## Typed Events
847
+
848
+ `TypedEvent<Target, Detail>` is a type-only helper that narrows
849
+ `CustomEvent` to a specific `target` and `detail`. Combine it with
850
+ `HTMLElementEventMap` augmentation for app-wide type-safe events.
568
851
 
569
852
  ```ts
570
- import { peek, untracked } from 'microtags'
853
+ import type { TypedEvent } from 'microtags'
571
854
 
572
- ctx.effect(() => {
573
- const current = peek(ctx.props.count) // does NOT subscribe
574
- untracked(() => {
575
- // reads inside here do not subscribe either
576
- })
855
+ type TabsChangedEvent = TypedEvent<InstanceType<typeof XTabs>, { index:number }>
856
+
857
+ declare global {
858
+ interface HTMLElementEventMap {
859
+ 'tabs:changed':TabsChangedEvent
860
+ }
861
+ }
862
+
863
+ // Emitting from setup:
864
+ ctx.emit('tabs:changed', { index: 2 })
865
+
866
+ // Listening, fully typed:
867
+ ctx.on(tabsEl, 'tabs:changed', ev => {
868
+ ev.target // the XTabs instance
869
+ ev.detail // { index:number }
577
870
  })
578
871
  ```
579
872
 
@@ -649,6 +942,7 @@ callback function mutates the DOM through properties like `textContent`.
649
942
  Nothing concatenates strings into markup, so there is no string-injection
650
943
  XSS vector.
651
944
 
945
+
652
946
  ## Subpath Exports
653
947
 
654
948
  ### `microtags/context`
@@ -719,6 +1013,10 @@ signal imperatively.
719
1013
 
720
1014
  ### `microtags/render`
721
1015
 
1016
+ ```ts
1017
+ import { render, renderList } from 'microtags/render'
1018
+ ```
1019
+
722
1020
  A keyed reconciler for rendering lists and single items from a
723
1021
  `<template>`, with no virtual DOM and no diffing library.
724
1022
 
@@ -846,7 +1144,8 @@ import {
846
1144
  coerceString,
847
1145
  coerceBoolean,
848
1146
  coerceJson,
849
- runCleanups,
1147
+ runAll,
1148
+ toAttributes
850
1149
  } from 'microtags/util'
851
1150
  ```
852
1151
 
@@ -872,14 +1171,14 @@ function coerceJson<T = unknown> (raw:string | null):T | undefined
872
1171
  - `coerceJson` -- `JSON.parse(raw)`, or `undefined` when the attribute is
873
1172
  absent or not valid JSON. Pass a type parameter to annotate the result.
874
1173
 
875
- #### `runCleanups`
1174
+ #### `runAll`
876
1175
 
877
1176
  Runs every function in the array, even if some throw. After all have run
878
1177
  it re-throws the first error encountered, so one failing teardown cannot
879
1178
  skip the rest. A `Cleanup` is a no-argument teardown function, `() => void`.
880
1179
 
881
1180
  ```ts
882
- function runCleanups (fns:Cleanup[]):void
1181
+ function runAll (fns:Cleanup[]):void
883
1182
  ```
884
1183
 
885
1184
  #### `toAttributes`
@@ -907,7 +1206,7 @@ function toAttributes (attrs:Attrs):string
907
1206
 
908
1207
  ---
909
1208
 
910
- ## Divergence from `nanotags`
1209
+ ## Differences from `nanotags`
911
1210
 
912
1211
  ### Reactivity primitive
913
1212
 
@@ -985,8 +1284,6 @@ These nanotags APIs have no microtags equivalent:
985
1284
 
986
1285
  - `ctx.getElement` / `ctx.getElements` -- declare refs with `withRefs`, or
987
1286
  call `ctx.host.querySelector` directly.
988
- - The `setup` mixin return value -- `setup` returns `void`; it does not
989
- assign members back onto the element.
990
1287
  - The `define(name, setupFn)` two-argument shorthand -- always use the
991
1288
  builder chain ending in `.setup()`.
992
1289