microtags 0.0.9 → 0.0.12

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