@nativele/ts-dom 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # Nativele
2
2
 
3
- A TypeScript-driven, HTML-first UI library.
3
+ <p align="center"><img src="https://raw.githubusercontent.com/Pixmi/nativele/main/docs/assets/banner.png" alt="Nativele: designed for AI agents, driven by TypeScript, defined in vanilla HTML" width="100%"></p>
4
+
5
+ **Designed for AI agents. Driven by TypeScript. Defined in vanilla HTML.**
4
6
 
5
7
  Nativele publishes this library as `@nativele/ts-dom` (import it from `@nativele/ts-dom`); its command line tool is `ts-dom`.
6
8
 
7
- A lightweight UI library: HTML describes the structure and where data goes, TypeScript owns behavior and state, and the native DOM renders it, with no virtual DOM and no template expressions. It is built for type safety and for working with AI agents: `tsc` checks every name the HTML uses, and every diagnostic says how to fix it. Complex UI is built by composing native custom elements.
9
+ A lightweight library for building web components: HTML describes the structure and where data goes, TypeScript owns behavior and state, and the native DOM renders it, with no virtual DOM and no template expressions. It is built for type safety and for working with AI agents: `tsc` checks every name the HTML uses, and every diagnostic says how to fix it. Complex UI is built by composing native custom elements.
8
10
 
9
- **Nativele is made for AI coding agents.** An agent writes a template and a class, runs `ts-dom check`, and corrects its own mistakes from the type errors: a name the HTML uses that the class does not have, an event handler that does not exist, list items of the wrong shape. There is no template language to learn and no hidden reactivity to reason about, so what the agent reads in the HTML and the class is all that happens. Every diagnostic from `ts-dom gen` and `ts-dom lint` names its rule and ends with a `Fix:` hint the agent can apply, [`llms.txt`](llms.txt) describes the whole API on one page, and [Rules for AI Agents](#rules-for-ai-agents) is a list you can paste into a project's agent instructions.
11
+ **Nativele is made for AI coding agents.** An agent writes a template and a class, runs `ts-dom check`, and corrects its own mistakes from the type errors: a name the HTML uses that the class does not have, an event handler that does not exist, list items of the wrong shape. There is no template language to learn and no hidden reactivity to reason about, so what the agent reads in the HTML and the class is all that happens. Every diagnostic from `ts-dom gen` and `ts-dom lint` names its rule and ends with a `Fix:` hint the agent can apply, [`llms.txt`](llms.txt) describes the whole API on one page, and [Nativele Skills](#nativele-skills) give agents verified procedures for building components and for using them with other tools.
10
12
 
11
13
  English | [繁體中文](README.zh-TW.md)
12
14
 
@@ -44,17 +46,28 @@ Six things cover most components:
44
46
  5. **Props go down, events go up**: the parent sets props; the child calls `this.emit("name", detail)` and the parent handles it with `data-on-name` on the child's tag.
45
47
  6. **A handler is a public method** `(event, hit)`; inside a list item `hit.key` is the item's key.
46
48
 
47
- ## Rules for AI Agents
49
+ ## Nativele Skills
50
+
51
+ Nativele ships Skills with the package: task procedures for coding agents, each with set-up steps, a complete example, known limitations, the commands that verify the result, and the exact versions it was verified with. The guides hold the facts about the API; a Skill tells an agent how to do a job with them.
52
+
53
+ ```sh
54
+ npx ts-dom skills # list the Skills in the installed version
55
+ npx ts-dom skills nativele-core # print one
56
+ npx ts-dom skills add nativele-core # copy it into .claude/skills/ (when the project has .claude) or .agents/skills/
57
+ ```
48
58
 
49
- Paste these into your project's agent instructions (`AGENTS.md`, `CLAUDE.md` or similar):
59
+ `npx ts-dom doctor` reads your `package.json` and suggests the Skills that match it, for example `nativele-react` when React is a dependency.
50
60
 
51
- - Write the `.html` and the `.ts` of a component together; never edit `x.html.ts` or `ts-dom-tags.ts`, which `ts-dom gen` writes.
52
- - After changing any `.html` or component, run `npx ts-dom check` (add `--json` to read the result as data). It regenerates the generated files, runs `tsc`, maps the template contract errors back to the template line and column, and runs the lint rules. Fix every issue it reports, following its `Fix:` hint, instead of casting a type error away.
53
- - Import templates as `./x.html.js`.
54
- - No expressions in HTML: put computed values in `derive`, and change state only with `this.set()`.
55
- - Every diagnostic from `ts-dom gen` and `ts-dom lint` ends with a `Fix:` hint; follow it. `npx ts-dom rules` lists all rule ids, and `npx ts-dom lint --a11y` also checks accessibility.
56
- - A `data-each` list cannot be nested: put the inner list in its own `.html` as a child component.
57
- - For a list of components, write `data-key` and `data-prop` on the `<template data-each>`, give the item component `role="listitem"` inside `<ul>` or `<ol>`, assign `this.lists.x.items = ...` with new objects for changed items (never mutate an assigned item), and let `tsc` check the items.
61
+ | Skill | Covers |
62
+ |---|---|
63
+ | `nativele-core` | building components: the `ts-dom check` loop, templates, state, lists, data flow |
64
+ | `nativele-react` | Nativele components in React 19: props, events, children and slots, lifecycle, TSX types |
65
+
66
+ Tiers: Official Skills are verified by the maintainers against an example project in CI; Community Skills are contributed and show their author and last verified versions; Experimental Skills are not fully verified yet. `ts-dom skills` marks a Skill as stale when its verification is more than six months old or does not cover the installed version.
67
+
68
+ Nativele stays a light UI layer and grows its integrations through Skills: a new Skill changes no runtime code and adds no dependency. The planned Skills and how to contribute one are in [the Skills guide](https://github.com/Pixmi/nativele/blob/main/skills/README.md).
69
+
70
+ If your agent does not load Skills, add this line to its instructions (`AGENTS.md`, `CLAUDE.md` or similar): "Before working on Nativele components, run `npx ts-dom skills nativele-core` and follow it."
58
71
 
59
72
  ## Quick Start
60
73
 
@@ -64,7 +77,7 @@ npx ts-dom gen # generate x.html.ts (fully typed) for every x.html; run it
64
77
  npx tsc # type-check and compile; no Vite or other bundler needed
65
78
  ```
66
79
 
67
- The four examples below are real, runnable files in `examples/readme/` (type-checked, linted, and tested).
80
+ The two examples below are real, runnable files in `examples/readme/` (type-checked, linted, and tested).
68
81
 
69
82
  ### Example 1: Counter (state, derived values, events, data-if)
70
83
 
@@ -99,139 +112,7 @@ define("my-counter", MyCounter);
99
112
 
100
113
  If the HTML uses a name that neither `state` nor `derive` has, or `data-on-click` points to a method that does not exist, `tsc` reports an error.
101
114
 
102
- ### Example 2: Todo list (lists, event delegation, `hit.key`)
103
-
104
- ```html
105
- <form data-on-submit="add">
106
- <input data-ref="input" aria-label="New todo" />
107
- <button type="submit">Add</button>
108
- </form>
109
- <ul>
110
- <template data-each="items">
111
- <li>
112
- <input type="checkbox" data-ref="check" data-on-change="toggle" />
113
- <span>{{ title }}</span>
114
- <button type="button" data-on-click="removeItem">Delete</button>
115
- </li>
116
- </template>
117
- </ul>
118
- <p>{{ remaining }} left</p>
119
- ```
120
-
121
- ```ts
122
- import { Component, define } from "@nativele/ts-dom";
123
- import type { EventHit } from "@nativele/ts-dom";
124
- import tpl from "./todo-list.html.js";
125
-
126
- interface Item {
127
- id: number;
128
- title: string;
129
- done: boolean;
130
- }
131
-
132
- export class TodoList extends Component(tpl, {
133
- state: { items: [] as Item[] },
134
- derive: { remaining: (s) => s.items.filter((i) => !i.done).length },
135
- }) {
136
- #nextId = 1;
137
-
138
- add(e: Event): void {
139
- e.preventDefault();
140
- const title = this.refs.input.value.trim();
141
- this.refs.input.value = "";
142
- if (title) this.set((s) => ({ items: [...s.items, { id: this.#nextId++, title, done: false }] }));
143
- }
144
- toggle(_e: Event, hit: EventHit): void {
145
- this.set((s) => ({ items: s.items.map((i) => (i.id === hit.key ? { ...i, done: !i.done } : i)) }));
146
- }
147
- removeItem(_e: Event, hit: EventHit): void {
148
- this.set((s) => ({ items: s.items.filter((i) => i.id !== hit.key) }));
149
- }
150
-
151
- // Automatic binding handles {{ remaining }}; the list is filled here.
152
- override render(view: { items: readonly Item[] }, changed: ReadonlySet<string>): void {
153
- if (!changed.has("items")) return;
154
- this.lists.items.replace(
155
- view.items,
156
- (i) => i.id,
157
- (i, { text, refs }) => {
158
- text.title = i.title;
159
- refs.check.checked = i.done;
160
- refs.check.setAttribute("aria-label", `Done: ${i.title}`);
161
- },
162
- { skipUnchanged: true },
163
- );
164
- }
165
- }
166
- define("todo-list", TodoList);
167
- ```
168
-
169
- - The `<template>` with `data-each` is the list item; automatic binding does not handle lists, so the list is filled in `render` with `replace`.
170
- - Events use delegation: for `data-on-change` and `data-on-click` inside a list item, the `hit.key` they receive is that item's key.
171
- - `{{ remaining }}` is a derived value and updates automatically.
172
-
173
- ### Example 3: Parent and child components (props down, events up)
174
-
175
- The child component owns only `props` (given by the parent, read-only). To change a value, it uses `emit` to ask the parent to handle it:
176
-
177
- ```html
178
- <button type="button" data-on-click="dec">-</button>
179
- <output>{{ value }}</output>
180
- <button type="button" data-on-click="inc">+</button>
181
- ```
182
-
183
- ```ts
184
- import { Component, define, event } from "@nativele/ts-dom";
185
- import tpl from "./quantity-input.html.js";
186
-
187
- /** A child component: the parent owns the value (a prop); the child only asks for a change through an event. */
188
- export class QuantityInput extends Component(tpl, {
189
- props: { value: 0, min: 0, max: 10 },
190
- emits: { "quantity-change": event<{ value: number }>() },
191
- }) {
192
- dec(): void {
193
- if (this.props.value > this.props.min) this.emit("quantity-change", { value: this.props.value - 1 });
194
- }
195
- inc(): void {
196
- if (this.props.value < this.props.max) this.emit("quantity-change", { value: this.props.value + 1 });
197
- }
198
- }
199
- define("quantity-input", QuantityInput);
200
- ```
201
-
202
- The parent uses the child's tag directly in its own HTML. `ts-dom gen` automatically `import`s the child component and gives `this.refs.qty` the child's full type:
203
-
204
- ```html
205
- <quantity-input data-ref="qty" min="1" max="5" data-on-quantity-change="onQuantity"></quantity-input>
206
- <p>Total: {{ total }}</p>
207
- ```
208
-
209
- ```ts
210
- import { Component, define } from "@nativele/ts-dom";
211
- import type { EmitEvent } from "@nativele/ts-dom";
212
- import type { QuantityInput } from "./quantity-input.js";
213
- import tpl from "./order-line.html.js";
214
-
215
- export class OrderLine extends Component(tpl, {
216
- state: { quantity: 1, price: 20 },
217
- derive: { total: (s) => s.quantity * s.price },
218
- }) {
219
- // Event up: the child asked for a new quantity; the parent decides.
220
- onQuantity(e: Event): void {
221
- this.set({ quantity: (e as EmitEvent<QuantityInput, "quantity-change">).detail.value });
222
- }
223
-
224
- // Data down: the child's props are typed (a wrong name or type is a tsc error).
225
- override render(view: { quantity: number }): void {
226
- this.refs.qty.value = view.quantity;
227
- }
228
- }
229
- define("order-line", OrderLine);
230
- ```
231
-
232
- The data flow goes in only one direction: the parent's state → the child's props (`this.refs.qty.value = ...`); the child's request → event → the parent's handler method.
233
-
234
- ### Example 4: A list of components (`items =`, props down, events up)
115
+ ### Example 2: A list of components (`items =`, props down, events up)
235
116
 
236
117
  When an item has anything of its own (state, events, a nested list), make it a component and fill the list with one assignment. The item component receives its task as a prop and sends changes up as events:
237
118
 
@@ -304,16 +185,16 @@ export class TaskBoard extends Component(tpl, { state: { tasks: [] as Task[], ne
304
185
  this.refs.title.value = "";
305
186
  }
306
187
  // A changed task is a new object: the list sets the prop only on items that are not the same object as before.
307
- toggleTask(_e: Event, hit: EventHit): void {
188
+ toggleTask(_e: Event, hit: EventHit<Task, "id">): void {
308
189
  this.set((s) => ({ tasks: s.tasks.map((t) => (t.id === hit.key ? { ...t, done: !t.done } : t)) }));
309
190
  }
310
- removeTask(_e: Event, hit: EventHit): void {
191
+ removeTask(_e: Event, hit: EventHit<Task, "id">): void {
311
192
  this.set((s) => ({ tasks: s.tasks.filter((t) => t.id !== hit.key) }));
312
193
  }
313
194
  sortByTitle(): void {
314
195
  this.set((s) => ({ tasks: [...s.tasks].sort((a, b) => a.title.localeCompare(b.title)) }));
315
196
  }
316
- override render(view: { tasks: readonly Task[] }, changed: ReadonlySet<string>): void {
197
+ override render(view: { tasks: readonly Task[] }, changed: ReadonlySet<"tasks">): void {
317
198
  if (changed.has("tasks")) this.lists.tasks.items = view.tasks;
318
199
  }
319
200
  }
@@ -322,753 +203,22 @@ define("task-board", TaskBoard);
322
203
 
323
204
  Adding, removing, sorting and toggling all go through `this.set()`; the list keeps the element of each task that stays (so a checkbox keeps its focus), creates one for a new task, removes the ones that are gone, and sets `task` only on items that are a new object. `tsc` checks that the assigned items match the `task` prop of `<task-row>` and have an `id`.
324
205
 
325
- ## Design Principles
326
-
327
- - HTML is the single source of structure.
328
- - TypeScript is the single source of behavior and state.
329
- - The DOM is the single source of truth for the UI; there is no virtual tree.
330
- - Updates are explicit, local DOM operations; there is no whole-tree diff.
331
- - Component lifecycle reuses Custom Elements.
332
- - State subscriptions exist only where you call `bind`.
333
- - There is no template language: HTML contains only **names**, never expressions.
334
- - Type safety: names are derived by codegen, and tsc and lint can check that the HTML and TS match.
335
-
336
- Comparison with Vue templates:
337
-
338
- | Vue | ts-dom | Difference |
339
- |---|---|---|
340
- | `{{ expr }}` | `{{ name }}` | Must be a single identifier, not a path or an operation |
341
- | `v-if` | `data-if="name"` | The condition is decided by TS calling `show(name, bool)` |
342
- | `v-for` | `<template data-each="name">` | The list is updated by TS calling methods such as `replace` |
343
- | `<slot>` | `<slot name>` | In Light mode the library moves the content; with `shadow: true` it uses native slots |
344
- | `<style scoped>` | `<style scoped>` | Implemented with native `@scope`; without `scoped` it is global |
345
- | `<template>` | The HTML file itself | One `.html` file is one component template |
346
-
347
- ## Installation and Setup
348
-
349
- You only need TypeScript; **no Vite or any other bundler is required**:
350
-
351
- ```sh
352
- npm i -D typescript
353
- npm i @nativele/ts-dom
354
- ```
355
-
356
- `tsconfig.json` (the same as in an ordinary TS project, with no special options):
357
-
358
- ```json
359
- {
360
- "compilerOptions": {
361
- "target": "ES2022",
362
- "module": "NodeNext",
363
- "moduleResolution": "NodeNext",
364
- "lib": ["ES2022", "DOM"],
365
- "strict": true,
366
- "rootDir": "src",
367
- "outDir": "out"
368
- }
369
- }
370
- ```
371
-
372
- Workflow: first generate the template modules, then compile and type-check with `tsc`.
373
-
374
- ```sh
375
- npx ts-dom gen # generate x.html.ts for every x.html under src/
376
- npx tsc # type-check and compile to out/
377
- ```
378
-
379
- Whitespace in text is kept as written. To collapse each run of whitespace into one space and drop line-break-only text between tags (so indented HTML does not add stray spaces), use `npx ts-dom gen --whitespace=collapse` or the Vite plugin option `whitespace: "collapse"`; `<pre>` and `<textarea>` are left alone.
380
-
381
- During development, use `npx ts-dom gen --watch` to keep watching `.html` files, together with `tsc --watch`.
382
-
383
- `npx ts-dom gen --check` writes nothing: it lists generated files that are missing, outdated (their template or a `define()` changed) or orphaned (their template was deleted), and exits with code 1 if there are any (`--json` for machine-readable output). Use it in CI if your project commits the generated files, or to confirm they are fresh before reading type errors.
384
-
385
- In your code, import templates with a `.js` suffix (standard NodeNext style; TS maps it to the generated `x.html.ts`):
386
-
387
- ```ts
388
- import tpl from "./user-card.html.js";
389
- ```
390
-
391
- ### Running in the browser (no bundler)
392
-
393
- The compiled JS is standard ES modules. Only one bare module specifier, `ts-dom`, needs to be mapped, and an import map does it:
394
-
395
- ```html
396
- <script type="importmap">
397
- { "imports": { "@nativele/ts-dom": "/node_modules/@nativele/ts-dom/dist/index.mjs" } }
398
- </script>
399
- <user-card></user-card>
400
- <script type="module" src="/out/main.js"></script>
401
- ```
402
-
403
- This workflow (installing only `typescript`, no Vite, from `gen` to running in a real browser) is verified automatically by `npm run smoke:no-bundler`.
404
-
405
- ### With Vite (optional)
406
-
407
- When you use a bundler you do not need an import map. The `@nativele/ts-dom/vite` plugin is just a convenience: it generates `.html.ts` automatically at build time, watches `.html` and regenerates during dev, and offers optional ARIA warnings.
408
-
409
- ```ts
410
- import { defineConfig } from "vite-plus"; // or "vite"
411
- import { tsDom } from "@nativele/ts-dom/vite";
412
-
413
- export default defineConfig({
414
- plugins: [tsDom()], // options: dirs (default ["src"]), runtime, whitespace, include, exclude, a11y
415
- });
416
- ```
417
-
418
- While the dev server runs, the plugin keeps the generated files in step: adding or editing a template regenerates its `x.html.ts` and reloads the page once if a page uses it (a save that changes nothing does not reload), deleting a template removes its `x.html.ts`, and adding or removing a `define()` updates `ts-dom-tags.ts` and the child imports of the templates. `dirs` are relative to Vite's `root`. The page reloads rather than hot-swapping the component because a custom element tag can be defined only once, so a new class cannot replace the running one.
419
-
420
- When installing, we recommend installing `vite-plus` first and then `ts-dom` (when both are installed in the same npm run, `vite` may not be hoisted to the top level).
421
-
422
- ### Lint (optional)
423
-
424
- ```ts
425
- // lint settings in vite.config.ts (vp lint / oxlint)
426
- lint: {
427
- ignorePatterns: ["node_modules", "dist", "**/*.html.ts"],
428
- jsPlugins: [{ name: "ts-dom", specifier: "@nativele/ts-dom/oxlint" }],
429
- rules: {
430
- "ts-dom/unused-markup": "error",
431
- "ts-dom/a11y": ["warn", { ignore: [] }], // optional: ARIA checks, see "ARIA / Accessibility Checks"
432
- "ts-dom/data-flow": ["warn", { ignore: [] }], // optional: avoid passing props and events layer by layer, see "Data Flow"
433
- },
434
- },
435
- ```
436
-
437
- Each rule id is also a rule of its own, so a project can enable rules one by one and give each its own severity, for example `"ts-dom/unused-ref": "warn"` or `"ts-dom/missing-handler": "error"` instead of the whole `ts-dom/unused-markup` group. `ts-dom rules` lists the ids.
438
-
439
- **In the editor.** Install the Oxc extension for VS Code (it runs `oxlint --lsp`) and keep the plugin in your oxlint config: the findings show on the template's import line as you type, and `missing-handler` offers a quick fix that adds the method to the component class. From the command line, `oxlint --fix-suggestions` applies the same fix. A fix can only change the file being linted, so problems that live in the HTML (an unused `data-ref`, broken markup) are reported but not fixed there; for markup structure and layout use a formatter such as oxfmt.
440
-
441
- Without oxlint, the same checks are available through the CLI:
442
-
443
- ```sh
444
- npx ts-dom lint [dirs...] # unused markup (errors) and data flow suggestions (warnings)
445
- npx ts-dom lint --a11y # additionally check ARIA / accessibility (warnings)
446
- npx ts-dom lint --strict # warnings also make the exit code 1 (errors always do)
447
- npx ts-dom lint --json # output JSON: { "issues": [{ file, line, col, severity, rule, message }] }
448
- npx ts-dom rules # list all rule ids and descriptions
449
- npx ts-dom rules --explain=unused-ref # why the rule exists, a wrong example and the fix
450
- npx ts-dom rules --json # every rule as data: { id, group, description, why, bad, good }
451
- ```
452
-
453
- Every message has the same format. It explains **what is wrong, why, and how to fix it**, and includes the line and column in the HTML:
454
-
455
- ```
456
- a.html:2:1 error [unused-ref] data-ref="dead" is never used by the component class. Fix: use this.refs.dead, or remove the attribute if the element needs no TypeScript access.
457
- a.html:3:1 warning [unknown-prop] <x-kid lable> is not a declared prop of <x-kid>. Declared props: label, count. Did you mean "label"? Fix: declare "lable" in the child's props option, or remove the attribute.
458
- ```
459
-
460
- Exit codes: 0 = no problems, 1 = there are errors (or warnings when `--strict` is used). Messages from the oxlint plugin start with `./x.html.js:line:column [rule id] message` and are reported on the `import` line.
461
-
462
- Recommended check workflow: `ts-dom check` (it runs `gen`, `tsc` and `lint` in one pass; see "Checks").
463
-
464
- ## How Types Are Generated
465
-
466
- Next to each `x.html`, an `x.html.ts` is generated (already added to `.gitignore`; a `ts-dom-tags.ts` is generated as well, see "Composition"). It is a real TS module that contains both the parsed markup of this template (as data that the runtime turns into DOM nodes with DOM calls, so no HTML string is parsed in the browser) and its schema, so `tsc` can compile and check it directly.
467
-
468
- ```ts
469
- // user-card.html.ts (auto-generated)
470
- import { template } from "@nativele/ts-dom";
471
- import type { El } from "@nativele/ts-dom";
472
- export default template<{
473
- refs: { "toggle": El<"button"> };
474
- lists: { "tags": { refs: {}; text: "label"; ifs: "hot" } };
475
- ifs: "admin";
476
- slots: "extra";
477
- text: "name";
478
- }>([["h2", {}, ["Hello, {{ name }}!"]], /* ... */]);
479
- ```
480
-
481
- Whole-page documents are page entry points, not templates, and are not processed. A file is a page when its content starts with `<!doctype html>` or `<html>` (comments before it are fine); the file name does not matter, so `index.html`, `main.html` and `login.html` all work. A template is a fragment of a component, so `<html>`, `<head>` and `<body>` are rejected in it (rule `document-tag`): the browser would drop them from the fragment and whatever you put on them would have no effect. To keep a file out of generation in another way, use `--exclude`.
482
-
483
- ## HTML Syntax
484
-
485
- There are only a few kinds of markup, plus one kind of hole.
486
-
487
- | Markup | Meaning | TS side |
488
- |---|---|---|
489
- | `data-ref="name"` | Get the element; the type is derived from the tag | `this.refs.name` |
490
- | `data-if="name"` | Conditional block, **hidden by default** (removed from the DOM); shown only when TS says so | `this.show("name", bool)` |
491
- | `<template data-each="name">` | List; the content must be exactly one root element | `this.lists.name` |
492
- | `<slot name="x">` | Slot | The user provides child nodes with `slot="x"` |
493
- | `{{ name }}` | Text hole; the same name may appear in multiple places | `this.text.name = value`, or bound automatically from `state` |
494
- | `data-on-click="method"` | Event: calls the component method of that name (event names must be lowercase) | `method(e, hit)`, see "Events" |
495
-
496
- Rules for holes:
497
-
498
- - A hole must be a single identifier. `{{ data.name }}` and `{{ a + 1 }}` cause an error at codegen time.
499
- - Holes are only for text nodes; they are not supported inside attributes (`title="{{ x }}"` is an error). When needed, get the element with `data-ref` and set it in TS.
500
- - Content inside `<style>` and `<script>` is not treated as holes.
501
- - A value of `null`, `undefined`, or `false` is displayed as an empty string.
502
-
503
- ## Components
504
-
505
- ```ts
506
- class MyEl extends Component(tpl) {} // default: Light DOM
507
- class Iso extends Component(tpl, { shadow: true }) {} // optional: Shadow DOM
508
- ```
509
-
510
- Provided members:
511
-
512
- | Member | Description |
513
- |---|---|
514
- | `refs` | The elements corresponding to `data-ref` |
515
- | `text` | Holes; assigning a value updates every place where the hole appears |
516
- | `lists` | List objects |
517
- | `show(name, on)` | Toggle a `data-if` (hidden by default) |
518
- | `props` / `emit(name, detail)` | When using the `props` and `emits` options: data given by the parent (read-only) and events emitted upward, see "Data Flow" |
519
- | `slotchange(name)` | Override to react to later changes of slot content in Light mode |
520
- | `assignedNodes(name)` | The nodes currently assigned to that slot (excluding fallback content); behaves the same in Light and Shadow |
521
- | `bind(store, fn)` | Subscribe to a store, call once immediately, and unsubscribe automatically when the element is removed |
522
- | `on(target, type, fn)` | Attach a native event listener, removed automatically when the element is removed; returns a function for manual removal. Event types and parameters are inferred from `HTMLElementEventMap` |
523
- | `onCleanup(fn)` | Register a cleanup function to run when the element is removed |
524
- | `root` | The element itself (default), or the `ShadowRoot` (`shadow: true`) |
525
- | `connected()` / `disconnected()` | Override to attach your own behavior |
526
-
527
- The lifecycle is the Custom Elements `connectedCallback` / `disconnectedCallback`; you can also override them directly (remember to call `super`).
528
-
529
- ### Light (default) and Shadow
530
-
531
- ts-dom does no style management, so by default it renders into the element itself (Light DOM): what you see in the DOM is the structure you wrote, CSS is global, and id references for forms and ARIA work as usual. `shadow: true` uses Shadow DOM instead.
532
-
533
- | | Light (default) | `shadow: true` |
534
- |---|---|---|
535
- | Render location | The element itself | The element's open shadow root |
536
- | Styles | Global; `<style scoped>` can scope locally | Native isolation: document styles do not get in, and the component's `<style>` does not leak out; `:host` works |
537
- | `<style>` | Extracted and injected into `document.head` only once | Stays inside the shadow root, not injected into `<head>`; the `scoped` attribute has no effect |
538
- | `<slot>` | Moved once at creation, then maintained by a `MutationObserver` | Native to the browser |
539
- | Slot change notification | Override `slotchange(name)` | **Native `slotchange` event** (`slot.addEventListener("slotchange", …)`); the `slotchange(name)` method is only called in Light mode |
540
- | `assignedNodes(name)` | Yes | Yes (equivalent to native `slot.assignedNodes()`) |
541
- | `state`, `props`, `derive`, `emit`, `data-on-*`, lists, `data-if`, attributes | All supported | All supported, with the same behavior |
542
- | Forms | Naturally compatible with outer forms | You must handle `ElementInternals` yourself |
543
-
544
- Shadow DOM is verified in real Chrome ([browser/shadow.test.ts](browser/shadow.test.ts), 18 tests), covering: rendering and style isolation, `:host`, native slots and `slotchange`, attributes and props (including assignment before upgrade), `state` / `derive` / `data-if`, event delegation (including non-bubbling `focus`), `emit` not bubbling to `window`, child components inside list items (`hit.key`), **a Shadow parent containing a Light child and a Light parent containing a Shadow child**, and the fact that content a user puts into a slot is not treated as the component's own `data-on-*`.
545
-
546
- Light-mode slot rules:
547
-
548
- - Matching is by `slot="name"`. Nodes without a slot attribute go into the default slot, and when there is no content, the fallback content inside `<slot>` is used.
549
- - `{{ }}`, `data-ref`, and `data-if` in user content are not parsed; they belong to the user's own DOM.
550
- - As with native Shadow DOM, only the **direct child nodes of the host element** are assigned (nodes nested deeper are not). Child nodes added to the element after creation are assigned to the matching slot; when the `slot` attribute of an assigned node is changed, it is reassigned immediately; when an assigned node is removed, the fallback content is restored if that slot becomes empty; nodes with no matching slot are not rendered, consistent with native behavior. These changes call `slotchange(name)` (the initial assignment at creation does not).
551
- - Dynamic changes rely on a `MutationObserver`, which observes only while the element is connected and is disconnected on removal.
552
-
553
- ```ts
554
- class Card extends Component(tpl) {
555
- override slotchange(name: "head" | "") { console.log("slot changed", name); }
556
- }
557
- ```
558
-
559
- ### Styles
560
-
561
- A top-level `<style>` in a template can be written in two ways in Light mode:
562
-
563
- ```html
564
- <style>
565
- user-card .tag { color: gray } /* global: shared by the whole document */
566
- </style>
567
- <style scoped>
568
- :scope { display: block } /* scoped: only applies inside <user-card> */
569
- .tag { color: gray }
570
- </style>
571
- ```
572
-
573
- - `<style>` **must be at the first level of the HTML** and must not be wrapped by other elements (including `<template data-each>` and `data-if` elements); otherwise codegen reports an error.
574
- - Both are injected into `document.head` only once and are not duplicated per instance.
575
- - In supporting browsers, `scoped` uses native CSS `@scope (<tag name>)`; where it is unsupported it falls back to CSS nesting automatically (`<tag name> { ... }`, with `:scope` converted to `&`). Neither needs a CSS parser. The precedence of `@scope` is decided by "proximity to the root", not by specificity alone.
576
- - In Light mode `:host` has no effect, and a warning is logged to the console at runtime; use a tag selector or `:scope` instead.
577
- - With `shadow: true`, `<style>` stays inside the shadow root and the `scoped` attribute has no effect.
578
-
579
- ## Data and View: state (optional)
580
-
581
- The simplest usage: give `Component` a `state`, and change data only with `this.set()`.
582
-
583
- ```ts
584
- class Counter extends Component(tpl, {
585
- state: { title: "Todo", items: [] as Item[], filter: "all" },
586
- derive: { remaining: (s) => s.items.filter((i) => !i.done).length }, // derived value
587
- }) {
588
- add(e: Event) {
589
- this.set((s) => ({ items: [...s.items, { id: nextId++, title: "x", done: false }] }));
590
- }
591
- override render(view, changed) {
592
- // whatever automatic binding cannot handle (lists, attributes, focus…) goes here
593
- if (changed.has("items")) this.lists.items.replace(view.items, (i) => i.id, (i, { text }) => (text.title = i.title));
594
- }
595
- }
596
- ```
597
-
598
- | Member | Description |
599
- |---|---|
600
- | `state` | The current data, read-only. Use `set` to change data |
601
- | `set(patch)` / `set(s => patch)` | **The only entry point for changing data**: shallow-merges the patch; **if no field actually changes (`Object.is`), it does nothing** and `render` is not called. When something changes, it updates the automatic bindings synchronously and then calls `render` |
602
- | `render(view, changed)` | Override it to handle updates other than automatic binding. `view` is `state` plus the derived values, and `changed` is the set of state fields that changed this time (all of them on the first render). Use `changed.has("x")` to skip the parts that did not change |
603
- | `batch(fn)` | Combine multiple `set` calls: after `fn` finishes, the update runs once and `render` is called once |
604
- | `derive` option | Derived values, usable directly by `{{ }}`, `data-if`, and `render`. They are recomputed after every state change, with **no dependency tracking** |
605
-
606
- **Automatic binding**: `{{ name }}` and `data-if="admin"` map to the same-named state field (or derived value) and are written automatically when the field's value changes; `data-if` shows the block based on truthiness. **Names are checked by tsc**: when the HTML uses a name that neither `state` nor `derive` has, `class X extends Component(...)` reports an error that lists the missing names (the error message is `missingStateKeys: "name"`). Lists (`data-each`) are not bound automatically, because the key and how each item is filled are for you to decide; use `replace` in `render`.
607
-
608
- Design trade-offs:
609
-
610
- - **Synchronous**: when `set` returns, the view has already been updated. There is no implicit asynchronous batching, so reading the DOM right after `set` always gives the new state. To combine updates, use `batch` explicitly.
611
- - **No fine-grained updates**: on every change, `render` re-runs as a whole, and its cost depends on what you write in it. For large lists, use `changed` to skip unnecessary redraws and enable `skipUnchanged` on `replace` (see "Lists").
612
- - `set` may be called inside `render`; it is processed in another round after the current one ends. If it has not stabilized after 50 consecutive rounds, a "suspected infinite loop" error is thrown.
613
- - When `state` is not used, the original `refs`, `text`, `show`, `bind`, and `createStore` work as before, fully backward compatible.
614
-
615
- **Read-only all the way down**: `state`, `props`, the `view` passed to `render` and the argument of each `derive` function are typed `DeepReadonly`: arrays and objects inside them cannot be changed in place. `this.state.items[0].done = true` or `this.state.items.push(x)` is a `tsc` error, because an object changed in place keeps its identity and the list and child props, which compare by reference, would not update. Pass new objects instead: `this.set((s) => ({ items: s.items.map((i) => (i.id === id ? { ...i, done: true } : i)) }))`. When you annotate `render`'s `view` or a helper that takes the state, use read-only types (`readonly Item[]`, or `DeepReadonly<State>` from `@nativele/ts-dom`).
616
-
617
- ## Events: data-on-*
618
-
619
- HTML writes only the event name and the method name; the logic is in TS:
620
-
621
- ```html
622
- <form data-on-submit="add">…</form>
623
- <template data-each="items"><li><button data-on-click="removeItem">✕</button></li></template>
624
- ```
625
-
626
- ```ts
627
- add(e: Event) { e.preventDefault(); … }
628
- removeItem(e: Event, hit: EventHit) { this.set((s) => ({ items: s.items.filter((i) => i.id !== hit.key) })); }
629
- ```
630
-
631
- - The method receives `(event, hit)`. `hit.el` is the element carrying the `data-on-*`; when the event happens inside a list item, `hit.key` is that item's key and `hit.item` is the data most recently passed to `update`; this also holds for events emitted by a child component placed inside the item. `hit.item` is `unknown` unless the handler annotates it, e.g. `hit: EventHit<Todo>`. If you need extra parameters, put them in `data-*` attributes and read `hit.el.dataset` in the method.
632
- - **Event delegation**: only one listener per event type is attached at the component root, so a longer list does not add listeners, and adding or removing items needs no rebinding. Non-bubbling events (`focus`, `blur`, `mouseenter`, etc.) use the capture phase and handle only the target itself; for bubbling events, the handler methods are called in order from the inside out, and `stopPropagation()` stops the chain.
633
- - **Only elements in the component's own template count**: content a user puts into a slot does not trigger anything even if it has `data-on-click`.
634
- - Only a method name may be written; `add(1)` and `a.b` cause an error at codegen time. A method name must not collide with DOM built-in methods or ts-dom members (for example `remove`, `click`, `focus`, `set`, `render`); otherwise they would be overridden, and codegen reports an error and suggests renaming.
635
- - **tsc checks that the method exists**: `define(name, Class)` checks that every method referenced by the HTML is a public member of the class, and reports an error listing the names when one is missing (`missingHandlers: "name"`). The check runs again when the element is connected to the page and throws a clear runtime error.
636
- - Event names must be lowercase (HTML attributes are case-insensitive); for mixed-case events such as `DOMContentLoaded`, use `this.on()` instead.
637
-
638
- ## Composition: Using Multiple Components Together
639
-
640
- Each `.html` plus its TS is one component (a custom element), and writing its tag in another template is composition:
641
-
642
- ```html
643
- <!-- signup-form.html -->
644
- <form-field data-ref="name" label="Name" data-on-field-input="onName"></form-field>
645
- <template data-each="entries">
646
- <li><entry-row data-ref="row" data-on-remove-entry="removeEntry"></entry-row></li>
647
- </template>
648
- ```
649
-
650
- ```ts
651
- render(v) {
652
- this.refs.name.value = v.name; // data down: the child's props, fully typed
653
- this.lists.entries.replace(v.entries, (x) => x.id, (x, { refs }) => { refs.row.name = x.name; });
654
- }
655
- onName(e: Event) { // event up: sent only to the direct parent of this tag
656
- this.set({ name: (e as EmitEvent<FormField, "field-input">).detail.value });
657
- }
658
- ```
659
-
660
- Composition needs three things, and ts-dom handles all of them:
661
-
662
- - **Child components are loaded automatically**: for a tag used in a template (its name contains a hyphen), if there is a `define("tag", Class)` in the scanned directories, `ts-dom gen` automatically adds `import "./child.js"` to the generated `x.html.ts`. HTML is the single source of dependencies, and you do not need to `import` child components yourself (referencing a type with only `import type` does not register the element, which is an easy trap to fall into). Components outside the directories, or third-party ones, still need to be imported by you.
663
- - **Child component types**: `ts-dom gen` scans `define("tag", Class)` (the class must be `export`ed) and generates `ts-dom-tags.ts`, which registers the components in `HTMLElementTagNameMap`. As a result, for `<form-field data-ref="name">` in a template, `refs.name` is `FormField`: prop names and types, and methods, are all checked by tsc. This file is already in `.gitignore` and, like `.html.ts`, is generated by `gen`.
664
- - **Slots can cross layers**: for content the parent puts into a child component's slot, its `{{ }}` belongs to the parent and is updated by the parent; `data-on-*` is also handled by the parent, and the child does not catch it by mistake.
665
-
666
- "Template fragment include" is not provided: it would become a template-language feature. To share a piece of structure, make it a component.
667
-
668
- ## Data Flow: props Down, Events Up
669
-
670
- `props` and `emits` are two options besides `state` that give the data flow between parent and child components clear rules:
671
-
672
- ```ts
673
- class FormField extends Component(tpl, {
674
- props: { label: "", value: "", error: "" }, // given by the parent (read-only)
675
- state: { touched: false }, // owned by itself
676
- derive: { showError: (s) => s.touched && s.error !== "" },
677
- emits: { "field-input": event<{ value: string }>() }, // events emitted upward
678
- }) {
679
- onInput() {
680
- this.set({ touched: true });
681
- this.emit("field-input", { value: this.refs.input.value });
682
- }
683
- }
684
- ```
685
-
686
- | | Owner | Who can change it | How |
687
- |---|---|---|---|
688
- | `state` | The component itself | The component itself | `this.set()` |
689
- | `props` | The parent | The parent | HTML attribute (`user-name="Ada"`) or JS property (`el.userName = "Ada"`) |
690
- | Events | Emitted by the component itself | Anyone can subscribe | `this.emit(name, detail)` |
691
-
692
- - The default value of a prop determines its type and how the attribute is converted: `string`, `number` (an invalid number warns and uses the default), `boolean` (the attribute being present means `true`; `"false"` and removal mean `false`). Arrays and objects can only be passed through JS properties. camelCase names map to kebab-case attributes: `maxLength` ↔ `max-length`.
693
- - Like `state` and `derive`, `props` can be bound automatically by `{{ }}` and `data-if`, and also appear in the `view` and `changed` of `render`; a prop assigned before the element is upgraded is not lost either.
694
- - Events in `emits` **do not bubble** by default, and only the child's own tag can receive them: the handler is always written on `<child data-on-xxx="method">` in the parent's HTML, so you can see from the HTML where data goes. Declare `event({ bubbles: true })` explicitly only when you need bubbling.
695
- - The event's data type is obtained from `EmitEvent<FormField, "field-input">` (`CustomEvent<{ value: string }>`).
696
-
697
- ### Safeguards in Types and at Runtime
698
-
699
- | Problem | Safeguard |
700
- |---|---|
701
- | Modifying a prop with `set()` inside the component | The type of `set` does not include props; a clear error is thrown at runtime |
702
- | `emit` of an undeclared event, wrong data type, or missing data that should be provided | tsc reports an error; an undeclared event throws an error at runtime |
703
- | `state` / `props` / `derive` with the same name | tsc reports an error (`conflictingKeys`); an error is thrown when `Component()` is called |
704
- | A prop name that is the same as a member of `HTMLElement` or ts-dom (`title`, `hidden`, `render`…) | tsc reports an error (`reservedPropNames`); an error is thrown at runtime |
705
- | `emits` with the same name as a native event (`click`, `input`…) | tsc reports an error (`nativeEventNames`); an error is thrown at runtime |
706
- | The parent gives the child a prop with a wrong name or type | tsc reports an error (types come from `HTMLElementTagNameMap`) |
707
-
708
- ### Avoiding Layer-by-Layer Passing (lint: `ts-dom/data-flow`)
709
-
710
- A common problem in Vue is re-emitting layer by layer and passing props down layer by layer (prop drilling and emit chains), which is held back only by habit. Besides "events do not bubble by default", there are lint hints here (suggestions only, not errors):
711
-
712
- | Id | Detection |
713
- |---|---|
714
- | `prop-forwarding` | Assigning your own prop as-is to a child component (`this.refs.kid.name = v.name`, where `name` is your own prop) |
715
- | `emit-forwarding` | A method that handles a child event merely `emit`s the event again (with the same name, or the whole method is just one `this.emit(...)`) |
716
- | `props-mutation` | Assigning to props inside the component (`this.count = …`) |
717
- | `custom-event-on-native` | Writing a custom event on the `data-on-*` of a native element (a child component's events do not bubble, so it would never be received) |
718
- | `unknown-prop` | An attribute written on a child component's tag is not a prop it declares (hints "Did you mean …") |
719
- | `invalid-prop-value` | The attribute cannot set that prop: the prop is not string / number / boolean (it can only be passed through JS properties) |
720
- | `unknown-event` | `data-on-xxx` is written on a child component's tag, but the child does not declare (`emits`) this event and it is not a native event either |
721
-
722
- The last three are **HTML-level props / emit checks**: `tsc` checks `refs.child.prop` and `emit()` in TypeScript, while these three rules check the child component attributes and events written in the **parent's HTML**. They read the child components from `define("tag", Class)` in the directories and extract the declarations from `Component(tpl, { props, emits })`; when no child component definition is found (for example a third-party component), nothing is checked.
723
-
724
-
725
- Recommended practices:
726
-
727
- - **Data and events cross only one layer**: the parent owns the data and passes props down, and the child notifies with events.
728
- - **Data shared across many layers uses a shared store**: put `createStore` in a separate module, and have the components that need it import it directly and subscribe with `this.bind(store, fn)`, so the intermediate layers do not need to know about it or forward it.
729
- - When an event has to cross two layers, handle it explicitly in the middle layer (it has its own logic) instead of forwarding it as-is.
730
-
731
- ```ts
732
- // session.ts — components at any depth can use it directly, no layer-by-layer passing
733
- export const session = createStore({ user: "Ada" });
734
- // deep component
735
- connected() { this.bind(session, (s) => (this.text.user = s.user)); }
736
- ```
737
-
738
- Enable it the same way as other lint rules: `"ts-dom/data-flow": ["warn", { ignore: [] }]`, or the CLI `npx ts-dom lint` (which also shows data flow warnings; adding `--strict` makes the exit code 1).
739
-
740
- ### A Small Reminder About Form Inputs
741
-
742
- When writing data back to an `<input>` in `render`, first compare whether the value is the same: when data returns to the child component through "input → emit → parent → props", writing the same value again makes the cursor jump to the end.
743
-
744
- ```ts
745
- if (changed.has("value") && this.refs.input.value !== v.value) this.refs.input.value = v.value;
746
- ```
747
-
748
- The example [examples/signup](examples/signup/signup-form.ts) does exactly this, and a real Chrome test verifies that the cursor position does not jump.
749
-
750
- ## Performance
751
-
752
- `npm run bench` runs a large list benchmark in real Chrome and prints this table. Each item shows one text; the times are the median of 5 runs and count script execution only (no layout or paint). They were measured on one machine (Chrome on WSL), so read them as proportions, not absolute numbers. "Direct DOM" is a hand-written keyed list with plain DOM calls, the floor any library adds to.
753
-
754
- | Approach | Items | Create | Update 10% | Reverse | Remove half | Append 1,000 |
755
- |---|---|---|---|---|---|---|
756
- | Components (`items =`) | 1,000 | 15.4 ms | 0.5 ms | 1.8 ms | 1.1 ms | 14.6 ms |
757
- | Components (`items =`) | 5,000 | 80.5 ms | 1.6 ms | 10 ms | 5.4 ms | 17.6 ms |
758
- | Components (`items =`) | 10,000 | 160.6 ms | 3.2 ms | 21 ms | 11.3 ms | 16 ms |
759
- | Plain elements (`replace()`) | 1,000 | 2.8 ms | 0.1 ms | 0.7 ms | 0.2 ms | 2.9 ms |
760
- | Plain elements (`replace()`) | 5,000 | 15.8 ms | 0.7 ms | 4.3 ms | 1.7 ms | 3.7 ms |
761
- | Plain elements (`replace()`) | 10,000 | 42 ms | 1.4 ms | 8.6 ms | 3.3 ms | 4.4 ms |
762
- | Direct DOM (baseline) | 1,000 | 0.8 ms | 0.2 ms | 1 ms | 0.3 ms | 0.8 ms |
763
- | Direct DOM (baseline) | 5,000 | 4 ms | 0.8 ms | 4.3 ms | 1.6 ms | 1.1 ms |
764
- | Direct DOM (baseline) | 10,000 | 8.7 ms | 1.7 ms | 10.9 ms | 3.9 ms | 1.6 ms |
765
-
766
- - Creating component items costs about 15 µs per item, because each item is a custom element with its own template, refs and lifecycle; plain elements cost about 3–4 µs and direct DOM about 1 µs.
767
- - Once the items exist, updating, reordering and removing them stay within about two times of direct DOM: only items that are a new object are touched, and kept elements are moved, not rebuilt.
768
- - A frame has about 16 ms. A list of a few thousand component items is comfortable; for tens of thousands, use plain elements with `replace()`, show a page or a window of the list at a time, or keep component items for the rows that need state and events.
769
- - Event delegation adds no listeners as the list grows.
770
-
771
206
  ## Examples
772
207
 
773
208
  - [examples/signup](examples/signup/signup-form.ts): a signup form. Three components composed (`signup-form` → `form-field`, `entry-row`), covering `props` down, `emits` up, child components inside lists (`hit.key`), `derive` validation, and form input without cursor jumps; the corresponding real Chrome test is in [browser/signup.test.ts](browser/signup.test.ts).
774
209
  - [examples/user-card](examples/user-card.ts): the minimal example, covering `state` automatic binding, `data-on-click`, lists, and slots.
775
210
  - [examples/todo](examples/todo/todo-app.ts): a complete todo list, covering `state` and `derive`, `data-on-*` event delegation (including `hit.key` for list items), filtering, an empty state, `aria-pressed`, and `skipUnchanged`; it has a corresponding real Chrome end-to-end test ([browser/todo.test.ts](browser/todo.test.ts)).
211
+ - [examples/recipes](examples/recipes/contact-app.ts): a contacts screen for the [Recipes](https://github.com/Pixmi/nativele/blob/main/docs/recipes.md) guide, covering loading and error states from a shared store, a filtered list, a modal dialog with form validation, and typed `hit.key`; its real Chrome test is [browser/recipes.test.ts](browser/recipes.test.ts).
776
212
 
777
- ## State: store (when not using state)
778
-
779
- ```ts
780
- const store = createStore({ count: 0 });
781
- store.get();
782
- store.set({ count: 1 });
783
- store.set((s) => ({ count: s.count + 1 }));
784
- const off = store.subscribe((s) => console.log(s)); // returns an unsubscribe function
785
- ```
786
-
787
- `this.bind` also accepts a read-only source that has only `get` and `subscribe`. There is no dependency tracking: after `set`, all subscribers are called; if the value is the same under `Object.is`, no notification is sent. It is a small utility for when the `state` option is not used, and you may also skip it entirely, manage state yourself, and write directly to `refs` / `text`. When you need automatic binding and `render`, use `state` above instead.
788
-
789
- ## Templates Without a Component
790
-
791
- `tpl.instantiate()` builds a template without a custom element and returns the same typed handles a component has: `root` (a `DocumentFragment`), `refs`, `lists`, `text` and `show`. Use it for small pieces created in code, such as a menu item added to a list:
792
-
793
- ```ts
794
- import itemTpl from "./menu-item.html.js";
795
-
796
- function menuItem(label: string): HTMLLIElement {
797
- const view = itemTpl.instantiate();
798
- view.text.label = label;
799
- view.refs.button.addEventListener("click", () => select(label));
800
- return view.rootElement; // typed by the template's top-level tag
801
- }
802
- ```
803
-
804
- `rootElement` is the template's only top-level element (a top-level `<style>` does not count); it exists in the type only when the template has exactly one, so a template that later gains a second root element makes `tsc` report the use.
805
-
806
- There is no host element, so `data-on-*` attributes do nothing here (listen on `refs`) and `<slot>` is not projected; use a component when you need those.
807
-
808
- ## Lists
809
-
810
- There are two ways to fill a list, and one rule decides between them: **an item that has anything of its own (state, events, a nested list) is a component, filled with `items =`; an item that is a few texts and `data-if` is plain elements, filled with `replace()`.** Example 4 shows the first, Example 2 the second.
811
-
812
- ### Items as components: `items = ...`
813
-
814
- Name the field that identifies an item and the property that receives it, on the `<template data-each>`; the item is one custom element:
815
-
816
- ```html
817
- <ul>
818
- <template data-each="todos" data-key="id" data-prop="todo">
819
- <todo-row role="listitem" data-on-toggled="toggle"></todo-row>
820
- </template>
821
- </ul>
822
- ```
823
-
824
- ```ts
825
- render(view, changed) {
826
- if (changed.has("todos")) this.lists.todos.items = view.todos;
827
- }
828
- toggle(_e: Event, hit: EventHit) { /* hit.key is the item's id */ }
829
- ```
830
-
831
- Assigning `items` keeps the element of each key that stays (moved with `moveBefore` where the browser has it, so focus and state survive), creates one for a new key, removes the ones that are gone, and sets `todo` on each element whose item is not the same object as last time. Use immutable updates: changing a field of an object you already assigned and assigning the same object again does not update the item component, because props are compared by reference; assign a new object instead, for example `{ ...todo, done: true }`. The item component declares `todo` in its `props`, shows it however it likes, and tells the parent with `emit`; the parent reads `hit.key` in the handler. Reading `items` returns what was last assigned. `tsc` checks the assignment: each item must have the type of the `todo` property that `<todo-row>` declares and a `data-key` field (`id`), and the item component must declare the `data-prop` property; a list without `data-key` and `data-prop` takes no items. This relies on the component being registered in `ts-dom-tags.ts` (an exported `define("todo-row", TodoRow)` in a scanned directory). When the component is defined in a scanned directory but its class is not exported, or `ts-dom-tags.ts` does not list it yet, the assignment is a type error that says so (`itemComponentClassNotExported`, `itemComponentMissingFromTagMap`); only a component from outside the scanned directories accepts any item with the `id` field. At runtime, assigning throws when the value is not an array of objects, when an item has no string or number `id`, when two items share one, or when the template has no `data-key` and `data-prop`.
832
-
833
- Inside a `<ul>` or `<ol>` the item component needs `role="listitem"`, because a custom element is not an `<li>`; the accessibility check (`list-structure`) asks for it. `data-each` cannot be nested, on purpose: an item is one component, so a list inside an item is that component's own `data-each`. Every template then holds one level, with its own `refs`, `text` and event handling.
834
-
835
- ### Items as plain elements: `replace()`
836
-
837
- ```html
838
- <ul>
839
- <template data-each="tags">
840
- <li><span>{{ label }}</span> <b data-if="hot">🔥</b></li>
841
- </template>
842
- </ul>
843
- ```
844
-
845
- ```ts
846
- this.lists.tags.replace(
847
- tags,
848
- (t) => t.id, // key
849
- (t, { text, refs, show, el }) => {
850
- text.label = t.label;
851
- show("hot", t.hot === true);
852
- },
853
- );
854
- ```
855
-
856
- | Method | Description |
857
- |---|---|
858
- | `replace(items, key, update, opts?)` | Keyed reconciliation: remove the extra items, create the missing ones, reorder in sequence, and call `update` for each item. With `opts.skipUnchanged: true`, `update` is skipped when the item reference is the same as last time (off by default: it is safe only when you use immutable updates; if you mutate objects directly, the view stays on the old value) |
859
- | `upsert(key, item, update)` | Add (at the end) or update a single item |
860
- | `remove(key)` | Remove a single item |
861
- | `clear()` | Clear everything |
862
- | `has(key)` / `get(key)` / `size` | Queries |
863
-
864
- `replace` only does one-dimensional key matching; the DOM nodes of existing items are kept and moved, and subtrees are not compared. The content of an item is entirely decided by your `update`.
865
-
866
- Notes:
867
-
868
- - `data-ref`, holes, and `data-if` inside an item belong to that item and do not appear in the component-level `refs` / `text`.
869
- - `data-if` cannot be placed on an item's root element; wrap it in one more layer.
870
- - `data-each` cannot be nested. Split it into two templates: put the inner list in its own `.html` (a child component with its own `data-each`) and use that component's tag inside the outer item.
871
- - `<slot>` cannot be placed inside an item.
872
-
873
- ### Moving items, connecting and subscriptions
874
-
875
- - In a browser with `moveBefore`, a moved item stays connected: neither `disconnected()` nor `connected()` runs, and its focus, scroll position and subscriptions are untouched.
876
- - In a browser without it, a moved item is taken out and put back: `disconnected()` runs and releases everything registered with `bind`, `on` and `onCleanup`, then `connected()` runs again. Its element, props and state stay the same.
877
- - So subscribe in `connected()` (`this.bind(store, ...)`, `this.on(...)`), not in the constructor or a field initializer: then the subscription is made again after a move in every browser. A subscription made in the constructor is released by the first move and never comes back.
878
- - Data that every item needs (a theme, the current user, a registry) belongs in a `createStore` that the item component binds in `connected()`, not copied into every item object.
879
-
880
- ### Order of list updates
881
-
882
- Assigning `items` sets the `data-prop` property of a new item before its element goes into the document, so the item component is connected with its item already set and its first render shows it. `replace()` and `upsert()` put a new item's element into the document first and then call `update`, so a child component placed in such an item is connected before the parent has set its properties; there, build per-item state when the child first receives data (for example in a property setter), and guard a render in `connected()` with the data it needs.
883
-
884
- ## Trusted Types, Translations and Icons
885
-
886
- - Trusted Types: pages that enforce `require-trusted-types-for 'script'` work with no policy, because `ts-dom gen` writes the markup as data and the runtime builds it with DOM calls. Your own code must avoid `innerHTML` too.
887
- - Translations: ts-dom has no i18n. Write the translated string into a hole, `this.text.title = t("title")`, or into `render()` for attributes; the HTML stays free of words if you want it that way.
888
- - Icons: ts-dom does not manage icons. Make an icon a custom element of your own that builds its SVG with `createElementNS` from whatever source you use, and place it in a template as a child component:
889
-
890
- ```html
891
- <app-icon name="send" class="size-4"></app-icon>
892
- ```
893
-
894
- - Reordering: a keyed list (`data-each`) moves its items with `moveBefore` where the browser has it, and every component accepts the move without being disconnected, so a moved item keeps its focus, scroll position and subscriptions.
895
-
896
- ## Checks
897
-
898
- Each tool checks what it can see, and none repeats another:
899
-
900
- | Tool | What it checks |
901
- |---|---|
902
- | A formatter such as oxfmt (`oxfmt --check`, or `vp fmt --check`) | Whether the template markup is well formed (a misnested closing tag such as `<p><div></p></div>` is a syntax error there, while the HTML parser the library uses would silently repair it into a different tree) and laid out consistently. It knows nothing of `{{ }}` or `data-*` names. Generate with whitespace `collapse` when you use one, or reformatting can change the spacing on the page. |
903
- | `ts-dom gen` | The template rules: holes, `data-each`, `data-if`, `data-on-*`, forbidden markup. |
904
- | `tsc` | That every name in the HTML exists in the component with the right type, and that lists, props and events fit. |
905
- | `ts-dom lint` / the oxlint plugin | What the HTML declares but the class never uses, data flow between components, accessibility. |
906
- | `ts-dom doctor` | The project set-up that the other tools depend on. |
907
- | `ts-dom check` | Runs `gen`, `tsc` and `lint` in one pass and points the template contract errors at the template. |
908
-
909
- Four complementary checks. **Every message has a stable rule id and explains what is wrong, why, and how to fix it**; `npx ts-dom rules` lists all rules.
910
-
911
- **When the set-up itself may be wrong: `npx ts-dom doctor [dirs...]`.** It checks what a project needs before any of the checks can work: a tsconfig with `strict`, the DOM lib, a module resolution that maps `./x.html.js` to the generated file and an `include` that covers the template folders; the runtime package being installed; templates imported as `./x.html.js` (not `./x.html`); generated files that are stale; the Vite plugin and the oxlint plugin being configured; and, when a formatter such as oxfmt or Prettier is set up, templates generated with whitespace `collapse`. Every issue has a stable rule id and a `Fix:` hint, and `--json` prints them as data.
912
-
913
- **One command for all of them: `npx ts-dom check [dirs...]`.** It reports template diagnostics, regenerates the generated files (with `--no-write` it reports stale ones instead), runs the project's own `tsc` (TypeScript 5, 6 or 7; `--project=` picks the tsconfig), and runs the lint rules (`--a11y` adds the accessibility checks). TypeScript errors that come from the template contract point at the template, not at the `Component(...)` call: a `{{ title }}` with nothing named `title` is reported at its line and column in the `.html` as `missing-binding`, and a missing handler as `missing-handler`; item component problems get their own rule ids, and the errors that only follow from a broken class are left out. `--json` prints the issues as data, and the exit code is 1 when there is an error (with `--strict`, a warning too).
914
-
915
- 1. **codegen diagnostics** (`ts-dom gen` and the Vite plugin, with line and column numbers; on errors they abort and write no files): duplicate `data-ref`, `data-each` without a single root element, `data-each` not on a `<template>`, nested `data-each`, an incomplete or invalid `data-key` / `data-prop`, a `data-key` list whose item is not one custom element, `data-if` on an item root element, `<slot>` inside an item, `<style>` not at the first level, invalid holes, holes inside attributes, invalid `data-on-*` (including names that collide with DOM or ts-dom members), `<html>`, `<head>` and `<body>` inside a template, and markup that runs code or Trusted Types blocks: `<script>`, `on*` attributes, `srcdoc`, and `javascript:` URLs.
916
- 2. **tsc**: an error when TypeScript uses a name the HTML does not declare (`this.refs.nope`, `this.show("nope", true)`); all constraints between `state`, `props`, `derive`, `emits` and the HTML (see "Data Flow"); child component prop names and types; `define()` checks that the methods for `data-on-*` exist.
917
- 3. **Runtime**: `Component()` and components, at definition, connection, and use, throw errors that carry a `[rule id]` and a fix for the same problems.
918
- 4. **lint** (`ts-dom/unused-markup`, `ts-dom/data-flow`, `ts-dom/a11y`): markup declared in the HTML but never used (errors, with the HTML location), data flow suggestions and child component contracts (warnings), ARIA (warnings).
919
-
920
- Rules of `unused-markup`:
921
-
922
- | Id | Detection |
923
- |---|---|
924
- | `unused-ref` | A `data-ref` is not used by the component |
925
- | `unused-if` | A `data-if` is never toggled (`data-if` is hidden by default, so the content never appears) |
926
- | `unused-text` | A `{{ hole }}` is never filled and displays blank |
927
- | `unused-list` | A `data-each` is never rendered, so the list is always empty |
928
- | `unused-item-name` | A name inside a list item is never referenced |
929
- | `missing-handler` | A method referenced by `data-on-*` is not defined by the class |
930
- | `no-importer` | No TypeScript file imports this template |
931
-
932
- lint compares per **class body** of `class X extends Component(tpl)`. When the `state` / `props` / `derive` options are used, `{{ }}` and `data-if` are handled by automatic binding and are not required to appear in the code, but the methods referenced by `data-on-*` must exist (if the code uses dynamic access such as `refs[k]`, `text[k]`, or `lists[k]`, it cannot tell which names are used, so it conservatively treats all of them as used). Hence importing multiple templates in the same file does not make them overwrite each other:
933
-
934
- ```ts
935
- import listTpl from "./list.html.js"; // data-ref="title"
936
- import cardTpl from "./card.html.js"; // data-ref="title" (Card never uses it)
937
- class List extends Component(listTpl) { connected() { this.refs.title.hidden = true } }
938
- class Card extends Component(cardTpl) {} // → reports that title in card.html is unused
939
- ```
940
-
941
- Note: refs, text, and so on should be used by the component's own class. Access through an instance outside the class (for example in tests) is not counted, so turn this rule off for test directories. When no corresponding class can be found (for example when `Component` is wrapped by a helper), it falls back to comparing the whole file.
942
-
943
- ## ARIA / Accessibility Checks
944
-
945
- **Off** by default. When enabled, it produces **warnings** (not errors) for places in the template where ARIA or a label is recommended. There are three ways to enable it, all sharing the same checks:
946
-
947
- ```ts
948
- // 1. lint: shown by both the editor and vp lint, warnings are reported on the import line (the message contains the html line and column)
949
- lint: { rules: { "ts-dom/a11y": ["warn", { ignore: ["img-alt"] }] } }
950
-
951
- // 2. build: shown in the terminal during dev / build
952
- plugins: [tsDom({ a11y: true })] // or { a11y: { ignore: ["img-alt"] } }
953
- ```
954
-
955
- ```sh
956
- # 3. CLI
957
- npx ts-dom lint --a11y [--strict]
958
- ```
959
-
960
- `ignore` takes rule ids and can turn off individual checks:
961
-
962
- | Id | Check |
963
- |---|---|
964
- | `img-alt` | `<img>` needs `alt` (use `alt=""` for decorative images) |
965
- | `control-name` | `<button>`, `<a href>`, and `<summary>` need a readable name (text, `{{ }}`, `<slot>`, `aria-label`, or `aria-labelledby`) |
966
- | `form-label` | `<input>`, `<select>`, and `<textarea>` need a `<label for>`, to be wrapped by a `<label>`, or `aria-label` |
967
- | `iframe-title` | `<iframe>` needs `title` |
968
- | `dialog-name` | `<dialog>` needs `aria-label` or `aria-labelledby` |
969
- | `heading-empty` | `h1`–`h6` must not be empty |
970
- | `aria-role` | `role` must be a valid ARIA role |
971
- | `aria-attr` | `aria-*` must be a valid ARIA attribute |
972
- | `role-tabindex` | A non-natively-focusable element with an interactive role (button, tab, checkbox…) needs `tabindex` |
973
- | `tabindex-positive` | Do not use a `tabindex` greater than 0 |
974
- | `hidden-focusable` | A focusable element must not have `aria-hidden="true"` |
975
- | `label-for` | `<label for>` must point to an `id` that exists in the template |
976
- | `id-in-list` | An `id` inside a `data-each` item is duplicated with each item; use `data-ref` or a class instead |
977
- | `duplicate-id` | An `id` must not be duplicated within a template |
978
- | `aria-idref` | The ids referenced by `aria-labelledby`, `describedby`, `controls`, `owns`, etc. must exist in the template (use `ignore` to turn it off when pointing to elements outside the template) |
979
- | `heading-order` | Heading levels must not skip (`h3` directly after `h1`), judged by document order within the template |
980
- | `table-header` | `<table>` needs `<th>`; for layout tables set `role="presentation"` |
981
- | `list-structure` | The children of `<ul>`/`<ol>` (including the root element of a `data-each` item) must be `<li>`; `<li>` must be inside `<ul>`, `<ol>`, or `<menu>` |
982
- | `landmark-label` | When a template has multiple `<nav>` or `<aside>`, they need to be distinguished with `aria-label` |
983
- | `redundant-role` | `role` is the same as the element's native semantics, for example `<button role="button">` |
984
- | `click-semantics` | `data-on-click` should be on an operable element (`button`, `a[href]`, `label`, or an element with an interactive role plus `tabindex`); otherwise keyboards and assistive technologies cannot use it |
985
-
986
- To avoid many false positives, elements with `data-ref` are treated as "managed by TS", and the checks whose "name or reference may be set in TS" are skipped for them: `img-alt`, `control-name`, `form-label`, `iframe-title`, `dialog-name`, `heading-empty`, `role-tabindex`, `table-header`, `landmark-label`, `aria-idref`.
987
-
988
- This is a static check that looks only at the HTML template: it does not analyze attributes that TS adds at runtime, and it does not cover items that can only be judged with actual rendering, such as color contrast, focus management, and keyboard interaction, so it cannot replace tools like axe and manual testing.
989
-
990
- ## Project Structure
991
-
992
- ```
993
- src/lib/
994
- component.ts Component base class, define
995
- list.ts keyed lists
996
- cond.ts data-if
997
- text.ts {{ }} holes
998
- slots.ts slots for Light DOM
999
- style.ts style injection for Light mode, scoped
1000
- store.ts createStore
1001
- template.ts Template / Schema types
1002
- codegen.ts parse5 parsing, diagnostics, generating .html.ts
1003
- vite-plugin.ts optional Vite plugin (@nativele/ts-dom/vite)
1004
- lint.ts unused markup and data flow checks
1005
- reserved.ts reserved names (collisions with DOM and ts-dom members)
1006
- a11y.ts ARIA / accessibility checks
1007
- oxlint-plugin.ts oxlint JS plugin (@nativele/ts-dom/oxlint)
1008
- src/cli.ts ts-dom gen / lint
1009
- examples/ user-card and todo list (todo) examples, demo entry
1010
- test/ unit tests (happy-dom)
1011
- browser/ real Chrome tests and fixtures
1012
- ```
1013
-
1014
- ## Developing This Project
1015
-
1016
- | Command | Description |
1017
- |---|---|
1018
- | `npm run dev` | Start the demo (`vp dev`) |
1019
- | `npm run build` | Build the demo into `demo-dist/` |
1020
- | `npm run build:lib` | Build the library into `dist/` |
1021
- | `npm run smoke` | After packing, verify `gen → tsc → lint → build` in a fresh project with Vite installed |
1022
- | `npm run smoke:no-bundler` | After packing, verify `gen → tsc → running in a browser` in a fresh project with **only typescript installed** |
1023
- | `npm run verify:pack` | Build and check the publishing configuration with `publint` and `attw` (runs automatically before `npm pack`) |
1024
- | `npm run gen` | Generate the `.html.ts` files for examples and tests |
1025
- | `npm run check` | Run `tsc` after `gen` |
1026
- | `npm test` | Unit tests (happy-dom) |
1027
- | `npm run test:browser` | Tests that run in real Chrome (see below) |
1028
- | `npm run lint` | `vp lint`, including `ts-dom/unused-markup` |
1029
- | `npm run lint:html` | The CLI version of the same checks |
1030
-
1031
- Examples in the repo point directly to `src/lib` through an alias in `vite.config.ts`, so no packing is needed first.
1032
-
1033
- ### Browser tests
1034
-
1035
- `npm run test:browser` starts `vp dev` and drives the system-installed Chrome with `playwright-core` (no browser is downloaded), verifying behavior that happy-dom cannot cover: native `@scope`, slot assignment for parser-created elements, dynamic slots through `MutationObserver`, and native slots and style isolation in Shadow DOM.
1036
-
1037
- | Environment variable | Description |
1038
- |---|---|
1039
- | `CHROME_BIN` | Path to Chrome, default `/usr/bin/google-chrome` |
1040
- | `TSDOM_CHROME_HOME` | HOME used by Chrome, default `/tmp/chome` (avoids crashpad write failures under WSL or a read-only home directory) |
1041
-
1042
- The tests always use `--no-sandbox --no-zygote`, which suits WSL and container environments; they can also run on an ordinary desktop.
1043
-
1044
- By default only Chromium is run, and CI also runs only Chromium: this library is a TS tool that relies on standard DOM APIs, not on CSS rendering, where browsers differ a lot. To try it on Firefox, use `BROWSER=firefox PLAYWRIGHT_BROWSERS_PATH=<path> npm run test:browser` (run `npx playwright-core install firefox` first; Firefox 157 has been verified to pass everything). WebKit is not verified.
1045
-
1046
- ## Design Decisions
1047
-
1048
- These are deliberate trade-offs, not missing features:
1049
-
1050
- - **`{{ }}` is only a single identifier**: paths and expressions would give the HTML a second data shape and slide toward a template language. If you need computation, do it in TS and fill a named hole.
1051
- - **No attribute binding**: get the element with `data-ref`, then set the attribute in TS.
1052
- - **`<slot>` cannot be placed inside a `data-each` item**: items repeat, but user-provided content exists only once, and the semantics of native Shadow DOM are also that only the first slot of a given name gets the content. To give each item its own content, you would need a mechanism like a Vue scoped slot that "passes the item data to the slot", which would require expressions. Fill it in with TS in the `update` callback instead, or make the item content another component.
1053
- - **The data flow goes in only one direction**: state is changed by itself, props are changed by the parent, events go up; `emit` does not bubble by default, and to cross layers you handle it explicitly or use a shared store; there is no implicit cross-layer passing.
1054
- - **One entry point for data, one place for the view** (`set` and `render`): "how data becomes the view" is concentrated in `render`, and to trace data changes you only need to look at the calls to `set`.
1055
- - **Synchronous rendering, no implicit asynchronous batching**: reading the DOM right after `set` always gives the new state, and tests need no waiting; to combine updates, use `batch` explicitly.
1056
- - **No fine-grained reactive system**: `derive` is recomputed every time and `render` re-runs as a whole every time, in exchange for predictable behavior and no dependency-tracking magic.
1057
- - **`data-if` is hidden by default**: state lives only in TS, and nothing is shown unless it says so.
1058
- - **`data-if` cannot be on an item root element and `data-each` cannot be nested**: removing the root element would make the list lose its ordering anchor, and one template then holds exactly one level of list. A nested list is a second template (a child component), which keeps each template's `refs`, `text` and event handling in a single scope.
213
+ ## Documentation
1059
214
 
1060
- ## Limitations
215
+ The full guide is in English:
1061
216
 
1062
- - When running directly in the browser (no bundler), you must provide an import map for `ts-dom` yourself, and relative imports must use the `.js` extension (the NodeNext convention).
1063
- - Event names for `data-on-*` must be lowercase; non-bubbling events handle only the target itself.
1064
- - Automatic import and tag types of child components scan only the directories given to `gen`; third-party components or those outside the directories must be imported by you, and you must declare `HTMLElementTagNameMap` yourself. A class passed to `define` without `export` gets no tag type.
1065
- - The `CustomEvent` received by an event handler method must be cast with `EmitEvent<child component, "event name">`, because HTML attributes cannot carry types.
1066
- - `props` can be passed through HTML attributes only when the default value is a string / number / boolean.
1067
- - Automatic binding covers only text holes and `data-if`; lists and attributes must be written in `render`.
1068
- - Version 0.x, and the API may still change (for example, the `Update` callback signature has changed twice); see [CHANGELOG.md](CHANGELOG.md) for the versioning policy.
1069
- - No SSR or hydration.
1070
- - No attribute binding (deliberately; use `refs`).
1071
- - The unused-markup lint compares against the class body text, and is looser for names inside list items (it only looks at identifiers).
1072
- - `<style scoped>` falls back to CSS nesting in browsers that do not support `@scope`, which requires browser support for nesting (mainstream since 2023).
1073
- - ARIA checks cover only common items in static HTML (see the list above) and are not a complete accessibility audit.
1074
- - Browser tests are based on Chromium; Firefox 157 passed manual verification, and WebKit (Safari) is not verified.
217
+ - [Templates: HTML syntax, generated types, templates without a component, Trusted Types, translations and icons](https://github.com/Pixmi/nativele/blob/main/docs/templates.md)
218
+ - [Components and data flow: state, props, derive, events, composition, stores](https://github.com/Pixmi/nativele/blob/main/docs/components.md)
219
+ - [Recipes: loading and error states, store data, filtered lists, dialogs, form validation](https://github.com/Pixmi/nativele/blob/main/docs/recipes.md)
220
+ - [Lists: lists of components with `items =`, plain lists with `replace()`, moving items](https://github.com/Pixmi/nativele/blob/main/docs/lists.md)
221
+ - [Setup, checks and lint: installation, Vite, oxlint, `ts-dom check`, `ts-dom doctor`, accessibility](https://github.com/Pixmi/nativele/blob/main/docs/tooling.md)
222
+ - [Performance: the large list benchmark](https://github.com/Pixmi/nativele/blob/main/docs/performance.md)
223
+ - [Design: principles, deliberate decisions, limitations](https://github.com/Pixmi/nativele/blob/main/docs/design.md)
224
+ - [Developing Nativele: project structure, tests](https://github.com/Pixmi/nativele/blob/main/docs/contributing.md)