# nanotags Tiny Web Components wrapper powered by Nano Stores reactivity. No Shadow DOM, typed builder API, reactive props and refs ## Agent Skills This project ships Agent Skills for AI coding agents in the `psd-coder/nanotags` GitHub repo. Install all of them with `npx skills add psd-coder/nanotags --skill '*' -a -g`, where `` is one of `claude-code`, `codex`, `cursor`, `github-copilot`, `gemini-cli`, `opencode`, `grok`, `pi`. To install one skill, pass its name: `npx skills add psd-coder/nanotags --skill -a -g` or `gh skill install psd-coder/nanotags --agent --scope user`. Drop `-g`, or pass `--scope project`, to install into the current project instead. - [nanotags-components](https://raw.githubusercontent.com/psd-coder/nanotags/HEAD/packages/nanotags/skills/nanotags-components/SKILL.md): Rules for writing idiomatic nanotags components (Custom Elements + nanostores). Use when creating, reviewing, or refactoring anything that calls define(), withProps, withRefs, withContexts, setup(ctx), ctx.effect, ctx.bind, ctx.emit, renderList, or createContext. Also use when porting hand-written DOM code to nanotags, or when component code reaches for manual querySelector, addEventListener, dispatchEvent, store.subscribe/listen, classList toggling, element maps, or setTimeout reconciliation. # Getting Started A thin Web Components wrapper powered by [Nano Stores](https://github.com/nanostores/nanostores) reactivity. It leans on the platform—Custom Elements, standard DOM, regular CSS—instead of reinventing them. The result is a typed, reactive component model with automatic cleanup in under **2.5 KB**. ```typescript import { define } from "nanotags"; define("copy-btn") .withRefs((r) => ({ code: r.one("code") })) .setup((ctx) => { ctx.on(ctx.host, "click", async () => { await navigator.clipboard.writeText(ctx.refs.code.textContent); ctx.host.classList.add("active"); setTimeout(() => ctx.host.classList.remove("active"), 1000); }); }); ``` ## Why nanotags? - **No Shadow DOM**: markup stays in the regular DOM, styled with normal CSS - **Reactive props** via [Nano Stores](https://github.com/nanostores/nanostores) atoms: subscribe when you need updates, `.get()` when you don't - **Typed fluent builder**: props, refs, and contexts are fully inferred through the chain - **Automatic cleanup**: event listeners, store subscriptions, and bindings are removed on disconnect - **Tree-shakeable**: `nanotags/render` and `nanotags/context` are separate entry points - **Standard Schema**: built-in validators plus any [Standard Schema](https://github.com/standard-schema/standard-schema)-compatible library (Valibot, Zod, ArkType) - **Hydration-first**: built for statically rendered markup. Pair with [Astro](https://astro.build/), server-rendered HTML, or any static-first setup to hydrate lightweight interactive islands ## Installation Install `nanotags nanostores`: - pnpm: `pnpm add nanotags nanostores` - npm: `npm install nanotags nanostores` - yarn: `yarn add nanotags nanostores` - bun: `bun add nanotags nanostores` [`nanostores`](https://github.com/nanostores/nanostores) is a peer dependency. **CDN** No build step needed. Add an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) to your HTML: Then use bare specifiers as usual: ```html ``` Sub-entry points (`nanotags/render`, `nanotags/context`) follow the same URL pattern. ## Agent skill The repo ships an [agent skill](https://github.com/psd-coder/nanotags/tree/HEAD/packages/nanotags/skills/nanotags-components) with the rules for idiomatic components and references for the exact API, Astro integration and testing. Install it so your coding agent writes idiomatic nanotags: Install every skill for your agent: - Claude Code - user scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a claude-code -g` - gh skill: `gh skill install psd-coder/nanotags --all --agent claude-code --scope user` - curl: `mkdir -p ~/.claude/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C ~/.claude/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - project scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a claude-code` - gh skill: `gh skill install psd-coder/nanotags --all --agent claude-code --scope project` - curl: `mkdir -p .claude/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C .claude/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - Codex - user scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a codex -g` - gh skill: `gh skill install psd-coder/nanotags --all --agent codex --scope user` - curl: `mkdir -p ~/.agents/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C ~/.agents/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - project scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a codex` - gh skill: `gh skill install psd-coder/nanotags --all --agent codex --scope project` - curl: `mkdir -p .agents/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C .agents/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - Cursor - user scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a cursor -g` - gh skill: `gh skill install psd-coder/nanotags --all --agent cursor --scope user` - curl: `mkdir -p ~/.cursor/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C ~/.cursor/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - project scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a cursor` - gh skill: `gh skill install psd-coder/nanotags --all --agent cursor --scope project` - curl: `mkdir -p .agents/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C .agents/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - GitHub Copilot - user scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a github-copilot -g` - gh skill: `gh skill install psd-coder/nanotags --all --agent github-copilot --scope user` - curl: `mkdir -p ~/.copilot/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C ~/.copilot/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - project scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a github-copilot` - gh skill: `gh skill install psd-coder/nanotags --all --agent github-copilot --scope project` - curl: `mkdir -p .agents/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C .agents/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - VS Code - user scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a github-copilot -g` - gh skill: `gh skill install psd-coder/nanotags --all --agent github-copilot --scope user` - curl: `mkdir -p ~/.copilot/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C ~/.copilot/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - project scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a github-copilot` - gh skill: `gh skill install psd-coder/nanotags --all --agent github-copilot --scope project` - curl: `mkdir -p .agents/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C .agents/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - Gemini CLI - user scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a gemini-cli -g` - gh skill: `gh skill install psd-coder/nanotags --all --agent gemini-cli --scope user` - curl: `mkdir -p ~/.gemini/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C ~/.gemini/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - project scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a gemini-cli` - gh skill: `gh skill install psd-coder/nanotags --all --agent gemini-cli --scope project` - curl: `mkdir -p .agents/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C .agents/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - OpenCode - user scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a opencode -g` - gh skill: `gh skill install psd-coder/nanotags --all --agent opencode --scope user` - curl: `mkdir -p ~/.config/opencode/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C ~/.config/opencode/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - project scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a opencode` - gh skill: `gh skill install psd-coder/nanotags --all --agent opencode --scope project` - curl: `mkdir -p .agents/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C .agents/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - Grok Build - user scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a grok -g` - gh skill: `gh skill install psd-coder/nanotags --all --agent grok --scope user` - curl: `mkdir -p ~/.grok/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C ~/.grok/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - project scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a grok` - gh skill: `gh skill install psd-coder/nanotags --all --agent grok --scope project` - curl: `mkdir -p .grok/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C .grok/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - Pi - user scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a pi -g` - gh skill: `gh skill install psd-coder/nanotags --all --agent pi --scope user` - curl: `mkdir -p ~/.agents/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C ~/.agents/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` - project scope - npx skills: `npx skills add psd-coder/nanotags --skill '*' -a pi` - gh skill: `gh skill install psd-coder/nanotags --all --agent pi --scope project` - curl: `mkdir -p .pi/skills && curl -fsSL https://codeload.github.com/psd-coder/nanotags/tar.gz/HEAD | tar -xz -C .pi/skills --strip-components=4 nanotags-HEAD/packages/nanotags/skills` ## Quick Start Start with markup: nanotags hydrates existing DOM rather than rendering from scratch: ```html 0 ``` Then define the component: ```typescript import { define } from "nanotags"; const Counter = define("x-counter") .withProps((p) => ({ count: p.number(), })) .withRefs((r) => ({ increment: r.one("button"), display: r.one("span"), })) .setup((ctx) => { ctx.on(ctx.refs.increment, "click", () => { ctx.props.$count.set(ctx.props.$count.get() + 1); }); ctx.effect(ctx.props.$count, (value) => { ctx.refs.display.textContent = String(value); }); }); ``` What happens: 1. **[`define("x-counter")`](api#define)**: starts the builder chain, names the custom element 2. **[`.withProps`](api#withprops)**: declares a `count` attribute parsed as a number, exposed as `ctx.props.$count` atom 3. **[`.withRefs`](api#withrefs)**: declares typed refs resolved via `[data-ref="name"]` selectors 4. **[`.setup`](api#setup)**: wires event listeners and reactive effects; everything is auto-cleaned on disconnect ## Lifecycle nanotags builds on the standard Custom Elements lifecycle with a thin reactive layer on top. **1. Constructor** Reactive prop stores are created and getter/setter descriptors are defined on the element instance. Attribute-backed props read their initial value from the DOM; JSON and property-only props start as `undefined`. The element is usable as a JS object at this point, but it is not connected to the DOM and [`setup()`](api#setup) has not run. **2. connectedCallback** All props are hydrated: each prop's `get` function is called, the raw value is parsed through the schema, and the corresponding atom is set. Then [`setup()`](api#setup) runs. If [`withContexts()`](api#withcontexts) was used, setup is deferred until all declared contexts resolve. See [Context API](cookbook#context-api) for details. **3. attributeChangedCallback** Fires when an observed attribute changes. The new value is validated through the prop's schema and pushed to the corresponding atom. Only attribute-backed props trigger this; JSON and property-only props are not observed. **4. disconnectedCallback** All registered cleanups run: event listeners are removed, store subscriptions are cancelled, and any [`onCleanup()`](api#oncleanup) callbacks execute. The cleanup list is then cleared. **Reconnection** Re-connecting a previously disconnected component runs [`setup()`](api#setup) again with a fresh cleanup scope. Props that were set programmatically (via the property setter) retain their values; attribute-backed props that were never set programmatically re-read from the DOM. This means: - All props are re-hydrated - Effects and listeners are re-registered - Refs are re-resolved from the current DOM - Mixin members are re-assigned **Cleanup guarantees** All of these are auto-cleaned on disconnect: - Event listeners registered via [`ctx.on()`](api#on) - Store subscriptions from [`ctx.effect()`](api#effect) - Bindings from [`ctx.bind()`](api#bind) - Custom teardown from [`ctx.onCleanup()`](api#oncleanup) If a cleanup function throws, the remaining cleanups still execute. The first error is re-thrown after all cleanups complete. ## FAQ **Why no Shadow DOM?** Shadow DOM brings encapsulation at the cost of complexity: styling piercing, slotting quirks, form participation hacks. nanotags targets server-rendered or static markup where global CSS is already the norm. Keeping elements in the light DOM means your existing styles, CSS frameworks, and dev tools work as expected. **How does it compare to Lit / Stencil / vanilla CE?** nanotags is intentionally minimal. It doesn't ship a template engine, virtual DOM, or lifecycle beyond connect/disconnect. If you need those, use Lit. If you want a thin reactivity layer over standard custom elements with TypeScript-first DX, nanotags is a good fit. **Does it work with SSR frameworks?** Yes. nanotags is designed for hydration: render markup on the server (Astro, PHP, Rails, static HTML), then hydrate on the client. Props are read from attributes, refs are resolved from existing DOM. **What happens when a context provider is missing?** When [`withContexts()`](api#withcontexts) is used, setup is deferred until all declared contexts resolve. If a provider never appears, setup never runs and the element stays inert. Use [`consume()`](api#contextconsume) directly for contexts that may or may not be available. # API ## Builder API The fluent builder creates components through an immutable chain. Each step returns a new builder instance, accumulating type information: ```typescript define("x-my-comp") // ComponentBuilder (entry point) .withProps((p) => ({ ... })) // + prop types .withRefs((r) => ({ ... })) // + ref types .setup((ctx) => { ... }); // terminates chain, registers element ``` `withProps()`, `withRefs()`, and `withContexts()` are optional and can appear in any order. `setup()` ends the chain: it calls `customElements.define` under the hood and returns a typed constructor. ### define `define(name)` / `define(name, setup)` Entry point for creating a component. Returns a `ComponentBuilder` when called with just a name. For simple components, pass setup directly as the second argument: ```typescript const Logger = define("x-logger", (ctx) => { console.log("connected:", ctx.host.tagName); }); ``` ### withProps Declare validated, reactive attributes via `withProps()`. Each prop becomes: - An observed HTML attribute (auto-synced via `attributeChangedCallback`) - A [Nano Stores](https://github.com/nanostores/nanostores) `WritableAtom` at `ctx.props.$propName` - A typed getter/setter on the element instance Prop names are camelCase in JS and automatically mapped to kebab-case HTML attributes: `tabIndex` → `tab-index`, `isOpen` → `is-open`. The property setter reflects back to the attribute. Four built-in validators coerce raw attribute strings to typed values: | Validator | Coercion | `null` attr | |-----------|----------|-------------| | `p.string()` | `String(val)` | `""` | | `p.number()` | `Number(val)` | `0` | | `p.boolean()` | `"true"` / `""` → `true`, `"false"` → `false` | `false` | | `p.oneOf(opts)` | Picklist enum, throws on invalid | throws | ```typescript .withProps((p) => ({ title: p.string(), count: p.number(), open: p.boolean(), size: p.oneOf(["s", "m", "l"]), })) ``` Each validator accepts an optional fallback. Pass `null` to make the prop nullable (inferred type becomes `T | null`): ```typescript .withProps((p) => ({ label: p.string(null), // string | null size: p.oneOf(["s", "m", "l"], null), // "s" | "m" | "l" | null })) ``` Props use [Standard Schema](https://github.com/standard-schema/standard-schema) internally, so any compatible validator (Valibot, Zod, ArkType) works as a custom prop schema. #### JSON props For complex data that doesn't fit into a string attribute, use `p.json()` with a Standard Schema validator: ```typescript import * as v from "valibot"; .withProps((p) => ({ items: p.json(v.array(v.object({ id: v.number(), name: v.string() })), []), config: p.json(v.nullable(v.object({ theme: v.string() }))), // no seed -> null })) ``` JSON props differ from attribute-backed props: - **Not observed**: not in `observedAttributes`, no `attributeChangedCallback` - **Setter writes to atom directly**: no attribute created in the DOM - **Hydrated once on connect**: reads from a `) --> ``` After hydration, set programmatically: ```typescript el.items = [{ id: 3, name: "Charlie" }]; // updates atom, no DOM attribute ``` #### Property-only props Set `attribute: false` to create a prop that exists only as a JS property and a Nano Stores atom, not an HTML attribute. The accessor is defined in the **constructor**, so it exists right after `document.createElement()`; the value stays `undefined` until the element connects or the property is assigned: ```typescript .withProps((p) => ({ label: p.string(), // attribute-backed value: { schema: p.string(""), attribute: false }, // property-only })) ``` This is useful when: - The value is large or complex (editor content, serialized state): writing kilobytes to a DOM attribute is wasteful - A component wraps an imperative resource (CodeMirror, canvas) and exposes its state as `.value` - A parent uses `ctx.bind()` on a child: `bind()` needs the `.value` property to exist from construction time, before setup runs The full `PropDef` shape: ```typescript type PropDef = { schema: StandardSchemaV1; attribute?: boolean; // default: true get?: (host: HTMLElement, key: string) => unknown; // custom hydration reader }; ``` ### withRefs Declare typed element references. Refs query the component's own DOM, skipping elements inside nested custom elements by default. `r.one()` returns a single element (throws if missing), `r.many()` returns an array (throws if empty). When you pass a tag name, it's used for both **type inference** and **runtime validation**: ```typescript .withRefs((r) => ({ trigger: r.one("button"), // HTMLButtonElement, validated items: r.many("li"), // HTMLLIElement[], validated circle: r.one("circle"), // SVGCircleElement, validated })) ``` SVG tag names work the same way, including camelCase tags like `feGaussianBlur`. For tags shared with HTML (`a`, `script`, `title`, `style`) the HTML variant wins; use the element generic (`r.one("a")`) for the SVG one. By default, refs match `[data-ref="name"]`. Non-tag strings (containing `.`, `#`, `[`, etc.) are treated as CSS selectors: ```typescript .withRefs((r) => ({ custom: r.one(".my-trigger"), // Element typed: r.one(".my-trigger"), // HTMLButtonElement (type-only) any: r.many(), // HTMLElement[] (no tag validation) })) ``` #### Ref ownership By default, a component **owns** only its direct refs: elements inside nested custom elements are skipped for proper encapsulation: ```html owned by x-parent owned by x-child, skipped by x-parent ``` With **slot-based composition** (e.g. Astro components passed into structural wrappers), refs may end up inside other custom elements even though they conceptually belong to the outer component. To claim ownership, prefix the ref with the component's tag name, `:`: ```html ``` The JS definition stays the same: each ref automatically checks both `[data-ref="name"]` (direct ownership) and `[data-ref="x-component:name"]` (explicit ownership). You can mix both in the same component. ### withContexts Declares required contexts on the builder. Setup is deferred until all contexts resolve: ```typescript import { createContext } from "nanotags/context"; const tabsCtx = createContext("tabs"); define("x-tab") .withContexts({ tabs: tabsCtx }) .setup((ctx) => { ctx.contexts.tabs.register(ctx.host); }); ``` If a context never resolves (no provider ancestor), setup never runs. For dynamic or conditional access, use `consume()` directly. See the [Context API guide](cookbook#context-api). ### setup The `setup()` function receives a [SetupContext](api#setup-context) object and runs when the component connects. It wires up behavior: event listeners, reactive effects, bindings, and cleanup. #### Mixin (return value) Returning an object from `setup()` assigns its members to the element instance, fully typed on the constructor: ```typescript const Timer = define("x-timer").setup((ctx) => { let id: number; return { start() { id = setInterval(() => ctx.emit("tick"), 1000); }, stop() { clearInterval(id); }, }; }); document.body.appendChild(new Timer()); document.querySelector("x-timer")!.start(); ``` Mixin members are available only after the element is connected (i.e. after `setup()` runs). ## Setup Context The `setup()` function receives a context object (`ctx`) with properties and methods for building reactive components. ### host The component's `HTMLElement` itself. Useful for reading layout, appending content, or passing to external APIs. ```typescript ctx.host.classList.add("active"); ``` ### props Reactive prop stores, each prefixed with `$`. Every prop declared via `withProps()` becomes a Nano Stores `WritableAtom`: ```typescript ctx.props.$count.get(); // read current value ctx.props.$count.set(42); // update value ctx.effect(ctx.props.$count, (val) => { /* react */ }); ``` ### refs Resolved element references declared via `withRefs()`: ```typescript ctx.refs.trigger // HTMLButtonElement ctx.refs.items // HTMLLIElement[] ``` ### contexts Resolved context values when `withContexts()` is used: ```typescript ctx.contexts.tabs.register(ctx.host); ctx.effect(ctx.contexts.tabs.$active, (active) => { /* ... */ }); ``` ### effect `effect(store, callback)` / `effect([storeA, storeB], callback)` Subscribe to one or more Nano Stores atoms. Callback fires immediately with current value(s). Unsubscribes on disconnect. ```typescript ctx.effect(ctx.props.$count, (count) => { ctx.refs.display.textContent = String(count); }); ctx.effect([storeA, storeB], (a, b) => { /* ... */ }); ``` ### bind `bind(store, element, options?)` Two-way binds a DOM control to a Nano Stores atom. The store is the source of truth: the control is set from the store on bind. **No options**. Auto-detects control type: | Control | Property | Listens to | |---------|----------|------------| | `input[type=checkbox]` | `.checked` | `change` | | `input[type=number\|range]` | `.valueAsNumber` | `input` | | `input` / `textarea` | `.value` | `input` | | `select` | `.value` | `change` | | Custom element with `.value` | `.value` | `change` | ```typescript ctx.bind($name, ctx.refs.nameInput); ctx.bind($agreed, ctx.refs.checkbox); ``` **With options**. Bind to any element property. Omit `event` for one-way (store → element): ```typescript ctx.bind($theme, el, { prop: "theme" }); // one-way ctx.bind($val, el, { prop: "value", event: "change" }); // two-way ``` When binding to a custom element, `.value` (or the target property) must be defined via `withProps()`, not as a mixin return value. Props are available from the constructor, while mixin members only exist after `connectedCallback`. ### on `on(target, type, listener, options?)` Attach event listeners with automatic cleanup. Accepts a single element, an array, `document`, or `window`. Event types are fully inferred for each target: ```typescript ctx.on(ctx.refs.trigger, "click", (e) => { /* ... */ }); ctx.on(ctx.refs.items, "mouseenter", (e) => { /* ... */ }); ctx.on(document, "keydown", (e) => { /* ... */ }); ``` ### emit `emit(event)` / `emit(name, detail?, options?)` Dispatch an existing `Event` or construct and dispatch a bubbling `CustomEvent` ```typescript ctx.emit(new CustomEvent("reset")); ctx.emit("change", { value: 42 }); ``` ### getElement `getElement(selector)` / `getElement(root, selector)` Asserts that the element exists and returns it with the correct type, no null checks or casting. Tag-name selectors narrow the return type automatically. Throws if nothing matches. ```typescript ctx.getElement("input"); // HTMLInputElement (throws if missing) ctx.getElement("circle"); // SVGCircleElement ctx.getElement(customParent, ".item"); // Element // type-only, no runtime tag check ctx.getElement(customRoot, ".my-input"); // HTMLInputElement ``` ### getElements `getElements(selector)` / `getElements(root, selector)` Works the same as [`getElement()`](api#getelement) but returns **all** matching elements as a typed `Array` (not a `NodeList`). Throws if none found. ```typescript ctx.getElements("button"); // HTMLButtonElement[] // type-only, no runtime tag check ctx.getElements<"input">(customRoot, ".field"); // HTMLInputElement[] ``` Both methods are primarily useful for dynamic queries inside [`renderList()`](api#renderlist) update callbacks or other imperative code. For static element references, prefer [refs](api#withrefs). For nullable results, use `ctx.host.querySelector()/querySelectorAll()` directly. ### onCleanup `onCleanup(callback)` Register arbitrary teardown logic to run on disconnect: ```typescript const raf = requestAnimationFrame(tick); ctx.onCleanup(() => cancelAnimationFrame(raf)); ``` ## Context API Cross-component communication for parent-child relationships. Import from the separate `nanotags/context` entry point (~0.4 KB). ```typescript import { createContext } from "nanotags/context"; ``` For a conceptual overview, see the [Context API guide](cookbook#context-api). ### createContext `createContext(name?)` Creates a typed context key with `provide()` and `consume()` methods. The optional `name` is used as the `Symbol` description for debugging. ```typescript type TabsAPI = { register: (el: Element) => void; $active: WritableAtom; }; const tabsContext = createContext("tabs"); ``` ### context.provide `contextKey.provide(ctx, value)` Registers the component as a context provider. Any descendant that consumes this context key will receive `value`. The `ctx` parameter requires `host` (HTMLElement) and `onCleanup`; the setup context satisfies this. The provider's event listener is auto-cleaned on disconnect. ```typescript define("x-tabs").setup((ctx) => { const $active = atom(""); tabsContext.provide(ctx, { register: (el) => { /* ... */ }, $active, }); }); ``` On connect, `provide()` also dispatches a `context-provider` event to resolve any pending consumers that connected before the provider. ### context.consume `contextKey.consume(ctx, callback)` Requests the context value from the nearest ancestor provider. The callback receives the provided value. ```typescript define("x-tab").setup((ctx) => { tabsContext.consume(ctx, (tabs) => { tabs.register(ctx.host); ctx.effect(tabs.$active, (active) => { /* ... */ }); }); }); ``` If a provider is already connected, the callback fires synchronously. If not, the request is queued and resolved when the provider calls `provide()`. Pending requests are cleaned up on disconnect. ## Rendering Keyed reconciliation utilities for dynamic content. Import from the separate `nanotags/render` entry point (~0.4 KB). ```typescript import { render, renderList } from "nanotags/render"; ``` Both `render()` and `renderList()` **own the entire container**: any child not part of the current render cycle is removed. Containers must be dedicated to rendered content. ### render `render(container, template, options?)` Single-item rendering. Options are optional. Omit for static templates (loading spinners, error states, empty placeholders): ```typescript render(container, loadingTpl); // static render(container, profileTpl, { // data-driven data: user, update: (el, u) => { el.setAttribute("name", u.name); }, }); ``` Switching templates replaces the previous element. Internally delegates to `renderList()` with a single-element array. ### renderList `renderList(container, template, options)` Reconcile a data array against DOM by key. Creates, updates, removes, and reorders elements without recreating the whole list. Skips `update` when the item reference hasn't changed (`===`). ```html
``` ```typescript ctx.effect($users, (users) => { renderList(ctx.refs.list, ctx.refs.rowTpl, { data: users, key: (user) => user.id, update: (el, user) => { ctx.getElement(el, ".name").textContent = user.name; }, }); }); ``` **Options:** | Option | Type | Description | |--------|------|-------------| | `data` | `readonly T[]` | Array of items to render | | `key` | `(item: T, index: number) => string \| number` | Unique key per item | | `update` | `(el: Element, item: T) => void` | Called on create and when the item reference changes | ## Testing API Helpers from `nanotags/testing` for testing components in jsdom or happy-dom. They work with any test runner. They never end up in a browser bundle, because nothing imports them at runtime. For a guided walkthrough, see [Testing](cookbook#testing) in the Cookbook. When a component is in `HTMLElementTagNameMap`, or you pass its class, the helpers are typed from its definition: `h` checks prop names and values, `mount` and `create` return the component's element type, and `ref`/`refs` only accept its ref names. ### h `h(target, props?, ...children): Markup` Builds the HTML a server would render for an element, in the shape of React's `createElement`. `target` is a tag name or a component class. Children are more `h` calls, or raw HTML strings for markup copied as is. ```typescript import { h, mount } from "nanotags/testing"; const markup = h( "x-chart", { size: "lg", disabled: false, bars: [{ label: "a", value: 1 }] }, h("ul", { "data-ref": "list" }), h("template", { "data-ref": "tpl" }, h("li")), ); const host = mount(markup); ``` | Prop | Written as | |------|------------| | Attribute prop (`size`) | an attribute with the kebab-cased name | | JSON prop from `p.json()` (`bars`) | a `