@nativele/ts-dom 0.0.0-stage → 0.2.1

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,3 +1,882 @@
1
- # Temporary Holding Version
1
+ # Nativele
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A TypeScript-driven, HTML-first UI library.
4
+
5
+ Nativele publishes this library as `@nativele/ts-dom` (import it from `@nativele/ts-dom`); its command line tool is `ts-dom`.
6
+
7
+ A small library (prototype) for managing HTML with TypeScript. HTML describes the structure and "where data goes", TypeScript owns behavior and state, and the DOM is the single source of truth for what is rendered.
8
+
9
+ English | [繁體中文](README.zh-TW.md)
10
+
11
+ ```html
12
+ <!-- user-card.html: HTML only describes structure, where data goes, and which method handles an event -->
13
+ <h2>Hello, {{ name }}!</h2>
14
+ <button type="button" data-on-click="rename">Rename</button>
15
+ <p data-if="admin">Admin</p>
16
+ ```
17
+
18
+ ```ts
19
+ import { Component, define } from "@nativele/ts-dom";
20
+ import tpl from "./user-card.html.js"; // generated by ts-dom gen, fully typed
21
+
22
+ class UserCard extends Component(tpl, {
23
+ state: { name: "Ada", admin: true }, // data lives only here; {{ name }} and data-if="admin" bind to the same-named fields automatically
24
+ }) {
25
+ rename() {
26
+ this.set({ name: "Grace" }); // the only entry point for changing data; the view updates automatically
27
+ }
28
+ }
29
+ define("user-card", UserCard);
30
+ ```
31
+
32
+ If the HTML uses a name that `state` does not have, or `data-on-click` points to a method that does not exist, `tsc` reports an error.
33
+
34
+ ## Quick Start
35
+
36
+ ```sh
37
+ npm i -D typescript && npm i @nativele/ts-dom
38
+ npx ts-dom gen # generate x.html.ts (fully typed) for every x.html
39
+ npx tsc # type-check and compile; no Vite or other bundler needed
40
+ ```
41
+
42
+ The three examples below are real, runnable files in `examples/readme/` (type-checked, linted, and tested).
43
+
44
+ ### Example 1: Counter (state, derived values, events, data-if)
45
+
46
+ The HTML only describes the structure, where data goes, and which method handles an event:
47
+
48
+ ```html
49
+ <p>Count: {{ count }} (double: {{ double }})</p>
50
+ <button type="button" data-on-click="inc">+1</button>
51
+ <button type="button" data-on-click="reset">Reset</button>
52
+ <p data-if="big">That is a lot of clicks!</p>
53
+ ```
54
+
55
+ TypeScript owns the data and behavior. `{{ count }}`, `{{ double }}`, and `data-if="big"` bind automatically to the same-named state or derived value, and data is changed only through `this.set()`:
56
+
57
+ ```ts
58
+ import { Component, define } from "@nativele/ts-dom";
59
+ import tpl from "./counter.html.js";
60
+
61
+ export class MyCounter extends Component(tpl, {
62
+ state: { count: 0 },
63
+ derive: { double: (s) => s.count * 2, big: (s) => s.count >= 10 },
64
+ }) {
65
+ inc(): void {
66
+ this.set((s) => ({ count: s.count + 1 }));
67
+ }
68
+ reset(): void {
69
+ this.set({ count: 0 });
70
+ }
71
+ }
72
+ define("my-counter", MyCounter);
73
+ ```
74
+
75
+ 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.
76
+
77
+ ### Example 2: Todo list (lists, event delegation, `hit.key`)
78
+
79
+ ```html
80
+ <form data-on-submit="add">
81
+ <input data-ref="input" aria-label="New todo" />
82
+ <button type="submit">Add</button>
83
+ </form>
84
+ <ul>
85
+ <template data-each="items">
86
+ <li>
87
+ <input type="checkbox" data-ref="check" data-on-change="toggle" />
88
+ <span>{{ title }}</span>
89
+ <button type="button" data-on-click="removeItem">Delete</button>
90
+ </li>
91
+ </template>
92
+ </ul>
93
+ <p>{{ remaining }} left</p>
94
+ ```
95
+
96
+ ```ts
97
+ import { Component, define } from "@nativele/ts-dom";
98
+ import type { EventHit } from "@nativele/ts-dom";
99
+ import tpl from "./todo-list.html.js";
100
+
101
+ interface Item {
102
+ id: number;
103
+ title: string;
104
+ done: boolean;
105
+ }
106
+
107
+ export class TodoList extends Component(tpl, {
108
+ state: { items: [] as Item[] },
109
+ derive: { remaining: (s) => s.items.filter((i) => !i.done).length },
110
+ }) {
111
+ #nextId = 1;
112
+
113
+ add(e: Event): void {
114
+ e.preventDefault();
115
+ const title = this.refs.input.value.trim();
116
+ this.refs.input.value = "";
117
+ if (title) this.set((s) => ({ items: [...s.items, { id: this.#nextId++, title, done: false }] }));
118
+ }
119
+ toggle(_e: Event, hit: EventHit): void {
120
+ this.set((s) => ({ items: s.items.map((i) => (i.id === hit.key ? { ...i, done: !i.done } : i)) }));
121
+ }
122
+ removeItem(_e: Event, hit: EventHit): void {
123
+ this.set((s) => ({ items: s.items.filter((i) => i.id !== hit.key) }));
124
+ }
125
+
126
+ // Automatic binding handles {{ remaining }}; the list is filled here.
127
+ override render(view: { items: Item[] }, changed: ReadonlySet<string>): void {
128
+ if (!changed.has("items")) return;
129
+ this.lists.items.replace(
130
+ view.items,
131
+ (i) => i.id,
132
+ (i, { text, refs }) => {
133
+ text.title = i.title;
134
+ refs.check.checked = i.done;
135
+ refs.check.setAttribute("aria-label", `Done: ${i.title}`);
136
+ },
137
+ { skipUnchanged: true },
138
+ );
139
+ }
140
+ }
141
+ define("todo-list", TodoList);
142
+ ```
143
+
144
+ - The `<template>` with `data-each` is the list item; automatic binding does not handle lists, so the list is filled in `render` with `replace`.
145
+ - 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.
146
+ - `{{ remaining }}` is a derived value and updates automatically.
147
+
148
+ ### Example 3: Parent and child components (props down, events up)
149
+
150
+ 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:
151
+
152
+ ```html
153
+ <button type="button" data-on-click="dec">-</button>
154
+ <output>{{ value }}</output>
155
+ <button type="button" data-on-click="inc">+</button>
156
+ ```
157
+
158
+ ```ts
159
+ import { Component, define, event } from "@nativele/ts-dom";
160
+ import tpl from "./quantity-input.html.js";
161
+
162
+ /** A child component: the parent owns the value (a prop); the child only asks for a change through an event. */
163
+ export class QuantityInput extends Component(tpl, {
164
+ props: { value: 0, min: 0, max: 10 },
165
+ emits: { "quantity-change": event<{ value: number }>() },
166
+ }) {
167
+ dec(): void {
168
+ if (this.props.value > this.props.min) this.emit("quantity-change", { value: this.props.value - 1 });
169
+ }
170
+ inc(): void {
171
+ if (this.props.value < this.props.max) this.emit("quantity-change", { value: this.props.value + 1 });
172
+ }
173
+ }
174
+ define("quantity-input", QuantityInput);
175
+ ```
176
+
177
+ 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:
178
+
179
+ ```html
180
+ <quantity-input data-ref="qty" min="1" max="5" data-on-quantity-change="onQuantity"></quantity-input>
181
+ <p>Total: {{ total }}</p>
182
+ ```
183
+
184
+ ```ts
185
+ import { Component, define } from "@nativele/ts-dom";
186
+ import type { EmitEvent } from "@nativele/ts-dom";
187
+ import type { QuantityInput } from "./quantity-input.js";
188
+ import tpl from "./order-line.html.js";
189
+
190
+ export class OrderLine extends Component(tpl, {
191
+ state: { quantity: 1, price: 20 },
192
+ derive: { total: (s) => s.quantity * s.price },
193
+ }) {
194
+ // Event up: the child asked for a new quantity; the parent decides.
195
+ onQuantity(e: Event): void {
196
+ this.set({ quantity: (e as EmitEvent<QuantityInput, "quantity-change">).detail.value });
197
+ }
198
+
199
+ // Data down: the child's props are typed (a wrong name or type is a tsc error).
200
+ override render(view: { quantity: number }): void {
201
+ this.refs.qty.value = view.quantity;
202
+ }
203
+ }
204
+ define("order-line", OrderLine);
205
+ ```
206
+
207
+ 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.
208
+
209
+ ## Design Principles
210
+
211
+ - HTML is the single source of structure.
212
+ - TypeScript is the single source of behavior and state.
213
+ - The DOM is the single source of truth for the UI; there is no virtual tree.
214
+ - Updates are explicit, local DOM operations; there is no whole-tree diff.
215
+ - Component lifecycle reuses Custom Elements.
216
+ - State subscriptions exist only where you call `bind`.
217
+ - There is no template language: HTML contains only **names**, never expressions.
218
+ - Type safety: names are derived by codegen, and tsc and lint can check that the HTML and TS match.
219
+
220
+ Comparison with Vue templates:
221
+
222
+ | Vue | ts-dom | Difference |
223
+ |---|---|---|
224
+ | `{{ expr }}` | `{{ name }}` | Must be a single identifier, not a path or an operation |
225
+ | `v-if` | `data-if="name"` | The condition is decided by TS calling `show(name, bool)` |
226
+ | `v-for` | `<template data-each="name">` | The list is updated by TS calling methods such as `replace` |
227
+ | `<slot>` | `<slot name>` | In Light mode the library moves the content; with `shadow: true` it uses native slots |
228
+ | `<style scoped>` | `<style scoped>` | Implemented with native `@scope`; without `scoped` it is global |
229
+ | `<template>` | The HTML file itself | One `.html` file is one component template |
230
+
231
+ ## Installation and Setup
232
+
233
+ You only need TypeScript; **no Vite or any other bundler is required**:
234
+
235
+ ```sh
236
+ npm i -D typescript
237
+ npm i @nativele/ts-dom
238
+ ```
239
+
240
+ `tsconfig.json` (the same as in an ordinary TS project, with no special options):
241
+
242
+ ```json
243
+ {
244
+ "compilerOptions": {
245
+ "target": "ES2022",
246
+ "module": "NodeNext",
247
+ "moduleResolution": "NodeNext",
248
+ "lib": ["ES2022", "DOM"],
249
+ "strict": true,
250
+ "rootDir": "src",
251
+ "outDir": "out"
252
+ }
253
+ }
254
+ ```
255
+
256
+ Workflow: first generate the template modules, then compile and type-check with `tsc`.
257
+
258
+ ```sh
259
+ npx ts-dom gen # generate x.html.ts for every x.html under src/
260
+ npx tsc # type-check and compile to out/
261
+ ```
262
+
263
+ 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.
264
+
265
+ During development, use `npx ts-dom gen --watch` to keep watching `.html` files, together with `tsc --watch`.
266
+
267
+ In your code, import templates with a `.js` suffix (standard NodeNext style; TS maps it to the generated `x.html.ts`):
268
+
269
+ ```ts
270
+ import tpl from "./user-card.html.js";
271
+ ```
272
+
273
+ ### Running in the browser (no bundler)
274
+
275
+ The compiled JS is standard ES modules. Only one bare module specifier, `ts-dom`, needs to be mapped, and an import map does it:
276
+
277
+ ```html
278
+ <script type="importmap">
279
+ { "imports": { "@nativele/ts-dom": "/node_modules/@nativele/ts-dom/dist/index.mjs" } }
280
+ </script>
281
+ <user-card></user-card>
282
+ <script type="module" src="/out/main.js"></script>
283
+ ```
284
+
285
+ This workflow (installing only `typescript`, no Vite, from `gen` to running in a real browser) is verified automatically by `npm run smoke:no-bundler`.
286
+
287
+ ### With Vite (optional)
288
+
289
+ 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.
290
+
291
+ ```ts
292
+ import { defineConfig } from "vite-plus"; // or "vite"
293
+ import { tsDom } from "@nativele/ts-dom/vite";
294
+
295
+ export default defineConfig({
296
+ plugins: [tsDom()], // options: dirs (default ["src"]), runtime, whitespace, include, exclude, a11y
297
+ });
298
+ ```
299
+
300
+ 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).
301
+
302
+ ### Lint (optional)
303
+
304
+ ```ts
305
+ // lint settings in vite.config.ts (vp lint / oxlint)
306
+ lint: {
307
+ ignorePatterns: ["node_modules", "dist", "**/*.html.ts"],
308
+ jsPlugins: [{ name: "ts-dom", specifier: "@nativele/ts-dom/oxlint" }],
309
+ rules: {
310
+ "ts-dom/unused-markup": "error",
311
+ "ts-dom/a11y": ["warn", { ignore: [] }], // optional: ARIA checks, see "ARIA / Accessibility Checks"
312
+ "ts-dom/data-flow": ["warn", { ignore: [] }], // optional: avoid passing props and events layer by layer, see "Data Flow"
313
+ },
314
+ },
315
+ ```
316
+
317
+ Without oxlint, the same checks are available through the CLI:
318
+
319
+ ```sh
320
+ npx ts-dom lint [dirs...] # unused markup (errors) and data flow suggestions (warnings)
321
+ npx ts-dom lint --a11y # additionally check ARIA / accessibility (warnings)
322
+ npx ts-dom lint --strict # warnings also make the exit code 1 (errors always do)
323
+ npx ts-dom lint --json # output JSON: { "issues": [{ file, line, col, severity, rule, message }] }
324
+ npx ts-dom rules # list all rule ids and descriptions
325
+ ```
326
+
327
+ 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:
328
+
329
+ ```
330
+ 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.
331
+ 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.
332
+ ```
333
+
334
+ 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.
335
+
336
+ Recommended check workflow: `ts-dom gen && tsc && ts-dom lint`.
337
+
338
+ ## How Types Are Generated
339
+
340
+ 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.
341
+
342
+ ```ts
343
+ // user-card.html.ts (auto-generated)
344
+ import { template } from "@nativele/ts-dom";
345
+ import type { El } from "@nativele/ts-dom";
346
+ export default template<{
347
+ refs: { "toggle": El<"button"> };
348
+ lists: { "tags": { refs: {}; text: "label"; ifs: "hot" } };
349
+ ifs: "admin";
350
+ slots: "extra";
351
+ text: "name";
352
+ }>([["h2", {}, ["Hello, {{ name }}!"]], /* ... */]);
353
+ ```
354
+
355
+ Whole-page documents (starting with `<!doctype>` or `<html>`, such as `index.html`) are page entry points, not templates, and are not processed.
356
+
357
+ ## HTML Syntax
358
+
359
+ There are only a few kinds of markup, plus one kind of hole.
360
+
361
+ | Markup | Meaning | TS side |
362
+ |---|---|---|
363
+ | `data-ref="name"` | Get the element; the type is derived from the tag | `this.refs.name` |
364
+ | `data-if="name"` | Conditional block, **hidden by default** (removed from the DOM); shown only when TS says so | `this.show("name", bool)` |
365
+ | `<template data-each="name">` | List; the content must be exactly one root element | `this.lists.name` |
366
+ | `<slot name="x">` | Slot | The user provides child nodes with `slot="x"` |
367
+ | `{{ name }}` | Text hole; the same name may appear in multiple places | `this.text.name = value`, or bound automatically from `state` |
368
+ | `data-on-click="method"` | Event: calls the component method of that name (event names must be lowercase) | `method(e, hit)`, see "Events" |
369
+
370
+ Rules for holes:
371
+
372
+ - A hole must be a single identifier. `{{ data.name }}` and `{{ a + 1 }}` cause an error at codegen time.
373
+ - 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.
374
+ - Content inside `<style>` and `<script>` is not treated as holes.
375
+ - A value of `null`, `undefined`, or `false` is displayed as an empty string.
376
+
377
+ ## Components
378
+
379
+ ```ts
380
+ class MyEl extends Component(tpl) {} // default: Light DOM
381
+ class Iso extends Component(tpl, { shadow: true }) {} // optional: Shadow DOM
382
+ ```
383
+
384
+ Provided members:
385
+
386
+ | Member | Description |
387
+ |---|---|
388
+ | `refs` | The elements corresponding to `data-ref` |
389
+ | `text` | Holes; assigning a value updates every place where the hole appears |
390
+ | `lists` | List objects |
391
+ | `show(name, on)` | Toggle a `data-if` (hidden by default) |
392
+ | `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" |
393
+ | `slotchange(name)` | Override to react to later changes of slot content in Light mode |
394
+ | `assignedNodes(name)` | The nodes currently assigned to that slot (excluding fallback content); behaves the same in Light and Shadow |
395
+ | `bind(store, fn)` | Subscribe to a store, call once immediately, and unsubscribe automatically when the element is removed |
396
+ | `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` |
397
+ | `onCleanup(fn)` | Register a cleanup function to run when the element is removed |
398
+ | `root` | The element itself (default), or the `ShadowRoot` (`shadow: true`) |
399
+ | `connected()` / `disconnected()` | Override to attach your own behavior |
400
+
401
+ The lifecycle is the Custom Elements `connectedCallback` / `disconnectedCallback`; you can also override them directly (remember to call `super`).
402
+
403
+ ### Light (default) and Shadow
404
+
405
+ 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.
406
+
407
+ | | Light (default) | `shadow: true` |
408
+ |---|---|---|
409
+ | Render location | The element itself | The element's open shadow root |
410
+ | 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 |
411
+ | `<style>` | Extracted and injected into `document.head` only once | Stays inside the shadow root, not injected into `<head>`; the `scoped` attribute has no effect |
412
+ | `<slot>` | Moved once at creation, then maintained by a `MutationObserver` | Native to the browser |
413
+ | Slot change notification | Override `slotchange(name)` | **Native `slotchange` event** (`slot.addEventListener("slotchange", …)`); the `slotchange(name)` method is only called in Light mode |
414
+ | `assignedNodes(name)` | Yes | Yes (equivalent to native `slot.assignedNodes()`) |
415
+ | `state`, `props`, `derive`, `emit`, `data-on-*`, lists, `data-if`, attributes | All supported | All supported, with the same behavior |
416
+ | Forms | Naturally compatible with outer forms | You must handle `ElementInternals` yourself |
417
+
418
+ 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-*`.
419
+
420
+ Light-mode slot rules:
421
+
422
+ - 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.
423
+ - `{{ }}`, `data-ref`, and `data-if` in user content are not parsed; they belong to the user's own DOM.
424
+ - 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).
425
+ - Dynamic changes rely on a `MutationObserver`, which observes only while the element is connected and is disconnected on removal.
426
+
427
+ ```ts
428
+ class Card extends Component(tpl) {
429
+ override slotchange(name: "head" | "") { console.log("slot changed", name); }
430
+ }
431
+ ```
432
+
433
+ ### Styles
434
+
435
+ A top-level `<style>` in a template can be written in two ways in Light mode:
436
+
437
+ ```html
438
+ <style>
439
+ user-card .tag { color: gray } /* global: shared by the whole document */
440
+ </style>
441
+ <style scoped>
442
+ :scope { display: block } /* scoped: only applies inside <user-card> */
443
+ .tag { color: gray }
444
+ </style>
445
+ ```
446
+
447
+ - `<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.
448
+ - Both are injected into `document.head` only once and are not duplicated per instance.
449
+ - 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.
450
+ - In Light mode `:host` has no effect, and a warning is logged to the console at runtime; use a tag selector or `:scope` instead.
451
+ - With `shadow: true`, `<style>` stays inside the shadow root and the `scoped` attribute has no effect.
452
+
453
+ ## Data and View: state (optional)
454
+
455
+ The simplest usage: give `Component` a `state`, and change data only with `this.set()`.
456
+
457
+ ```ts
458
+ class Counter extends Component(tpl, {
459
+ state: { title: "Todo", items: [] as Item[], filter: "all" },
460
+ derive: { remaining: (s) => s.items.filter((i) => !i.done).length }, // derived value
461
+ }) {
462
+ add(e: Event) {
463
+ this.set((s) => ({ items: [...s.items, { id: nextId++, title: "x", done: false }] }));
464
+ }
465
+ override render(view, changed) {
466
+ // whatever automatic binding cannot handle (lists, attributes, focus…) goes here
467
+ if (changed.has("items")) this.lists.items.replace(view.items, (i) => i.id, (i, { text }) => (text.title = i.title));
468
+ }
469
+ }
470
+ ```
471
+
472
+ | Member | Description |
473
+ |---|---|
474
+ | `state` | The current data, read-only. Use `set` to change data |
475
+ | `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` |
476
+ | `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 |
477
+ | `batch(fn)` | Combine multiple `set` calls: after `fn` finishes, the update runs once and `render` is called once |
478
+ | `derive` option | Derived values, usable directly by `{{ }}`, `data-if`, and `render`. They are recomputed after every state change, with **no dependency tracking** |
479
+
480
+ **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`.
481
+
482
+ Design trade-offs:
483
+
484
+ - **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.
485
+ - **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").
486
+ - `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.
487
+ - When `state` is not used, the original `refs`, `text`, `show`, `bind`, and `createStore` work as before, fully backward compatible.
488
+
489
+ ## Events: data-on-*
490
+
491
+ HTML writes only the event name and the method name; the logic is in TS:
492
+
493
+ ```html
494
+ <form data-on-submit="add">…</form>
495
+ <template data-each="items"><li><button data-on-click="removeItem">✕</button></li></template>
496
+ ```
497
+
498
+ ```ts
499
+ add(e: Event) { e.preventDefault(); … }
500
+ removeItem(e: Event, hit: EventHit) { this.set((s) => ({ items: s.items.filter((i) => i.id !== hit.key) })); }
501
+ ```
502
+
503
+ - 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.
504
+ - **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.
505
+ - **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`.
506
+ - 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.
507
+ - **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.
508
+ - Event names must be lowercase (HTML attributes are case-insensitive); for mixed-case events such as `DOMContentLoaded`, use `this.on()` instead.
509
+
510
+ ## Composition: Using Multiple Components Together
511
+
512
+ Each `.html` plus its TS is one component (a custom element), and writing its tag in another template is composition:
513
+
514
+ ```html
515
+ <!-- signup-form.html -->
516
+ <form-field data-ref="name" label="Name" data-on-field-input="onName"></form-field>
517
+ <template data-each="entries">
518
+ <li><entry-row data-ref="row" data-on-remove-entry="removeEntry"></entry-row></li>
519
+ </template>
520
+ ```
521
+
522
+ ```ts
523
+ render(v) {
524
+ this.refs.name.value = v.name; // data down: the child's props, fully typed
525
+ this.lists.entries.replace(v.entries, (x) => x.id, (x, { refs }) => { refs.row.name = x.name; });
526
+ }
527
+ onName(e: Event) { // event up: sent only to the direct parent of this tag
528
+ this.set({ name: (e as EmitEvent<FormField, "field-input">).detail.value });
529
+ }
530
+ ```
531
+
532
+ Composition needs three things, and ts-dom handles all of them:
533
+
534
+ - **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.
535
+ - **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`.
536
+ - **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.
537
+
538
+ "Template fragment include" is not provided: it would become a template-language feature. To share a piece of structure, make it a component.
539
+
540
+ ## Data Flow: props Down, Events Up
541
+
542
+ `props` and `emits` are two options besides `state` that give the data flow between parent and child components clear rules:
543
+
544
+ ```ts
545
+ class FormField extends Component(tpl, {
546
+ props: { label: "", value: "", error: "" }, // given by the parent (read-only)
547
+ state: { touched: false }, // owned by itself
548
+ derive: { showError: (s) => s.touched && s.error !== "" },
549
+ emits: { "field-input": event<{ value: string }>() }, // events emitted upward
550
+ }) {
551
+ onInput() {
552
+ this.set({ touched: true });
553
+ this.emit("field-input", { value: this.refs.input.value });
554
+ }
555
+ }
556
+ ```
557
+
558
+ | | Owner | Who can change it | How |
559
+ |---|---|---|---|
560
+ | `state` | The component itself | The component itself | `this.set()` |
561
+ | `props` | The parent | The parent | HTML attribute (`user-name="Ada"`) or JS property (`el.userName = "Ada"`) |
562
+ | Events | Emitted by the component itself | Anyone can subscribe | `this.emit(name, detail)` |
563
+
564
+ - 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`.
565
+ - 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.
566
+ - 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.
567
+ - The event's data type is obtained from `EmitEvent<FormField, "field-input">` (`CustomEvent<{ value: string }>`).
568
+
569
+ ### Safeguards in Types and at Runtime
570
+
571
+ | Problem | Safeguard |
572
+ |---|---|
573
+ | Modifying a prop with `set()` inside the component | The type of `set` does not include props; a clear error is thrown at runtime |
574
+ | `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 |
575
+ | `state` / `props` / `derive` with the same name | tsc reports an error (`conflictingKeys`); an error is thrown when `Component()` is called |
576
+ | 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 |
577
+ | `emits` with the same name as a native event (`click`, `input`…) | tsc reports an error (`nativeEventNames`); an error is thrown at runtime |
578
+ | The parent gives the child a prop with a wrong name or type | tsc reports an error (types come from `HTMLElementTagNameMap`) |
579
+
580
+ ### Avoiding Layer-by-Layer Passing (lint: `ts-dom/data-flow`)
581
+
582
+ 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):
583
+
584
+ | Id | Detection |
585
+ |---|---|
586
+ | `prop-forwarding` | Assigning your own prop as-is to a child component (`this.refs.kid.name = v.name`, where `name` is your own prop) |
587
+ | `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(...)`) |
588
+ | `props-mutation` | Assigning to props inside the component (`this.count = …`) |
589
+ | `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) |
590
+ | `unknown-prop` | An attribute written on a child component's tag is not a prop it declares (hints "Did you mean …") |
591
+ | `invalid-prop-value` | The attribute cannot set that prop: the prop is not string / number / boolean (it can only be passed through JS properties) |
592
+ | `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 |
593
+
594
+ 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.
595
+
596
+
597
+ Recommended practices:
598
+
599
+ - **Data and events cross only one layer**: the parent owns the data and passes props down, and the child notifies with events.
600
+ - **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.
601
+ - 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.
602
+
603
+ ```ts
604
+ // session.ts — components at any depth can use it directly, no layer-by-layer passing
605
+ export const session = createStore({ user: "Ada" });
606
+ // deep component
607
+ connected() { this.bind(session, (s) => (this.text.user = s.user)); }
608
+ ```
609
+
610
+ 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).
611
+
612
+ ### A Small Reminder About Form Inputs
613
+
614
+ 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.
615
+
616
+ ```ts
617
+ if (changed.has("value") && this.refs.input.value !== v.value) this.refs.input.value = v.value;
618
+ ```
619
+
620
+ 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.
621
+
622
+ ## Performance
623
+
624
+ Measurement environment: real Chrome (WSL), one text hole per item, counting only script execution time (excluding layout and paint), a single measurement, so treat it as an order-of-magnitude reference.
625
+
626
+ | List size | Initial creation | Identical data | Change 1 item | Reverse order |
627
+ |---|---|---|---|---|
628
+ | 1,000 | about 11 ms | about 0.6 ms | about 0.4 ms | about 1.3 ms |
629
+ | 5,000 | about 23–34 ms | about 2 ms | about 0.9 ms | about 6–8 ms |
630
+ | 10,000 | about 65 ms | about 9 ms | about 2 ms | about 19 ms |
631
+
632
+ "Identical data" is the cost of `render` re-running the whole list every time: text nodes are not written when their value has not changed, so it is cheap. The budget for one frame is about 16 ms, so re-running tens of thousands of items on every keystroke gets tight; use `changed` to skip, use `skipUnchanged`, or split the list into smaller ones. Event delegation does not add listeners as the number of items grows.
633
+
634
+ ## Examples
635
+
636
+ - [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).
637
+ - [examples/user-card](examples/user-card.ts): the minimal example, covering `state` automatic binding, `data-on-click`, lists, and slots.
638
+ - [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)).
639
+
640
+ ## State: store (when not using state)
641
+
642
+ ```ts
643
+ const store = createStore({ count: 0 });
644
+ store.get();
645
+ store.set({ count: 1 });
646
+ store.set((s) => ({ count: s.count + 1 }));
647
+ const off = store.subscribe((s) => console.log(s)); // returns an unsubscribe function
648
+ ```
649
+
650
+ `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.
651
+
652
+ ## Templates Without a Component
653
+
654
+ `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:
655
+
656
+ ```ts
657
+ import itemTpl from "./menu-item.html.js";
658
+
659
+ function menuItem(label: string): HTMLElement {
660
+ const view = itemTpl.instantiate();
661
+ view.text.label = label;
662
+ view.refs.button.addEventListener("click", () => select(label));
663
+ return view.root.firstElementChild as HTMLElement;
664
+ }
665
+ ```
666
+
667
+ 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.
668
+
669
+ ## Lists
670
+
671
+ ```html
672
+ <ul>
673
+ <template data-each="tags">
674
+ <li><span>{{ label }}</span> <b data-if="hot">🔥</b></li>
675
+ </template>
676
+ </ul>
677
+ ```
678
+
679
+ ```ts
680
+ this.lists.tags.replace(
681
+ tags,
682
+ (t) => t.id, // key
683
+ (t, { text, refs, show, el }) => {
684
+ text.label = t.label;
685
+ show("hot", t.hot === true);
686
+ },
687
+ );
688
+ ```
689
+
690
+ | Method | Description |
691
+ |---|---|
692
+ | `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) |
693
+ | `upsert(key, item, update)` | Add (at the end) or update a single item |
694
+ | `remove(key)` | Remove a single item |
695
+ | `clear()` | Clear everything |
696
+ | `has(key)` / `get(key)` / `size` | Queries |
697
+
698
+ `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`.
699
+
700
+ Notes:
701
+
702
+ - `data-ref`, holes, and `data-if` inside an item belong to that item and do not appear in the component-level `refs` / `text`.
703
+ - `data-if` cannot be placed on an item's root element; wrap it in one more layer.
704
+ - `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.
705
+ - `<slot>` cannot be placed inside an item.
706
+
707
+ ## Trusted Types, Translations and Icons
708
+
709
+ - 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.
710
+ - 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.
711
+ - 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:
712
+
713
+ ```html
714
+ <app-icon name="send" class="size-4"></app-icon>
715
+ ```
716
+
717
+ - 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.
718
+
719
+ ## Checks
720
+
721
+ 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.
722
+
723
+ 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`, `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), and markup that runs code or Trusted Types blocks: `<script>`, `on*` attributes, `srcdoc`, and `javascript:` URLs.
724
+ 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.
725
+ 3. **Runtime**: `Component()` and components, at definition, connection, and use, throw errors that carry a `[rule id]` and a fix for the same problems.
726
+ 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).
727
+
728
+ Rules of `unused-markup`:
729
+
730
+ | Id | Detection |
731
+ |---|---|
732
+ | `unused-ref` | A `data-ref` is not used by the component |
733
+ | `unused-if` | A `data-if` is never toggled (`data-if` is hidden by default, so the content never appears) |
734
+ | `unused-text` | A `{{ hole }}` is never filled and displays blank |
735
+ | `unused-list` | A `data-each` is never rendered, so the list is always empty |
736
+ | `unused-item-name` | A name inside a list item is never referenced |
737
+ | `missing-handler` | A method referenced by `data-on-*` is not defined by the class |
738
+ | `no-importer` | No TypeScript file imports this template |
739
+
740
+ 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:
741
+
742
+ ```ts
743
+ import listTpl from "./list.html.js"; // data-ref="title"
744
+ import cardTpl from "./card.html.js"; // data-ref="title" (Card never uses it)
745
+ class List extends Component(listTpl) { connected() { this.refs.title.hidden = true } }
746
+ class Card extends Component(cardTpl) {} // → reports that title in card.html is unused
747
+ ```
748
+
749
+ 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.
750
+
751
+ ## ARIA / Accessibility Checks
752
+
753
+ **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:
754
+
755
+ ```ts
756
+ // 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)
757
+ lint: { rules: { "ts-dom/a11y": ["warn", { ignore: ["img-alt"] }] } }
758
+
759
+ // 2. build: shown in the terminal during dev / build
760
+ plugins: [tsDom({ a11y: true })] // or { a11y: { ignore: ["img-alt"] } }
761
+ ```
762
+
763
+ ```sh
764
+ # 3. CLI
765
+ npx ts-dom lint --a11y [--strict]
766
+ ```
767
+
768
+ `ignore` takes rule ids and can turn off individual checks:
769
+
770
+ | Id | Check |
771
+ |---|---|
772
+ | `img-alt` | `<img>` needs `alt` (use `alt=""` for decorative images) |
773
+ | `control-name` | `<button>`, `<a href>`, and `<summary>` need a readable name (text, `{{ }}`, `<slot>`, `aria-label`, or `aria-labelledby`) |
774
+ | `form-label` | `<input>`, `<select>`, and `<textarea>` need a `<label for>`, to be wrapped by a `<label>`, or `aria-label` |
775
+ | `iframe-title` | `<iframe>` needs `title` |
776
+ | `dialog-name` | `<dialog>` needs `aria-label` or `aria-labelledby` |
777
+ | `heading-empty` | `h1`–`h6` must not be empty |
778
+ | `aria-role` | `role` must be a valid ARIA role |
779
+ | `aria-attr` | `aria-*` must be a valid ARIA attribute |
780
+ | `role-tabindex` | A non-ts-domy-focusable element with an interactive role (button, tab, checkbox…) needs `tabindex` |
781
+ | `tabindex-positive` | Do not use a `tabindex` greater than 0 |
782
+ | `hidden-focusable` | A focusable element must not have `aria-hidden="true"` |
783
+ | `label-for` | `<label for>` must point to an `id` that exists in the template |
784
+ | `id-in-list` | An `id` inside a `data-each` item is duplicated with each item; use `data-ref` or a class instead |
785
+ | `duplicate-id` | An `id` must not be duplicated within a template |
786
+ | `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) |
787
+ | `heading-order` | Heading levels must not skip (`h3` directly after `h1`), judged by document order within the template |
788
+ | `table-header` | `<table>` needs `<th>`; for layout tables set `role="presentation"` |
789
+ | `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>` |
790
+ | `landmark-label` | When a template has multiple `<nav>` or `<aside>`, they need to be distinguished with `aria-label` |
791
+ | `redundant-role` | `role` is the same as the element's native semantics, for example `<button role="button">` |
792
+ | `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 |
793
+
794
+ 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`.
795
+
796
+ 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.
797
+
798
+ ## Project Structure
799
+
800
+ ```
801
+ src/lib/
802
+ component.ts Component base class, define
803
+ list.ts keyed lists
804
+ cond.ts data-if
805
+ text.ts {{ }} holes
806
+ slots.ts slots for Light DOM
807
+ style.ts style injection for Light mode, scoped
808
+ store.ts createStore
809
+ template.ts Template / Schema types
810
+ codegen.ts parse5 parsing, diagnostics, generating .html.ts
811
+ vite-plugin.ts optional Vite plugin (@nativele/ts-dom/vite)
812
+ lint.ts unused markup and data flow checks
813
+ reserved.ts reserved names (collisions with DOM and ts-dom members)
814
+ a11y.ts ARIA / accessibility checks
815
+ oxlint-plugin.ts oxlint JS plugin (@nativele/ts-dom/oxlint)
816
+ src/cli.ts ts-dom gen / lint
817
+ examples/ user-card and todo list (todo) examples, demo entry
818
+ test/ unit tests (happy-dom)
819
+ browser/ real Chrome tests and fixtures
820
+ ```
821
+
822
+ ## Developing This Project
823
+
824
+ | Command | Description |
825
+ |---|---|
826
+ | `npm run dev` | Start the demo (`vp dev`) |
827
+ | `npm run build` | Build the demo into `demo-dist/` |
828
+ | `npm run build:lib` | Build the library into `dist/` |
829
+ | `npm run smoke` | After packing, verify `gen → tsc → lint → build` in a fresh project with Vite installed |
830
+ | `npm run smoke:no-bundler` | After packing, verify `gen → tsc → running in a browser` in a fresh project with **only typescript installed** |
831
+ | `npm run verify:pack` | Build and check the publishing configuration with `publint` and `attw` (runs automatically before `npm pack`) |
832
+ | `npm run gen` | Generate the `.html.ts` files for examples and tests |
833
+ | `npm run check` | Run `tsc` after `gen` |
834
+ | `npm test` | Unit tests (happy-dom) |
835
+ | `npm run test:browser` | Tests that run in real Chrome (see below) |
836
+ | `npm run lint` | `vp lint`, including `ts-dom/unused-markup` |
837
+ | `npm run lint:html` | The CLI version of the same checks |
838
+
839
+ Examples in the repo point directly to `src/lib` through an alias in `vite.config.ts`, so no packing is needed first.
840
+
841
+ ### Browser tests
842
+
843
+ `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.
844
+
845
+ | Environment variable | Description |
846
+ |---|---|
847
+ | `CHROME_BIN` | Path to Chrome, default `/usr/bin/google-chrome` |
848
+ | `TSDOM_CHROME_HOME` | HOME used by Chrome, default `/tmp/chome` (avoids crashpad write failures under WSL or a read-only home directory) |
849
+
850
+ The tests always use `--no-sandbox --no-zygote`, which suits WSL and container environments; they can also run on an ordinary desktop.
851
+
852
+ 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.
853
+
854
+ ## Design Decisions
855
+
856
+ These are deliberate trade-offs, not missing features:
857
+
858
+ - **`{{ }}` 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.
859
+ - **No attribute binding**: get the element with `data-ref`, then set the attribute in TS.
860
+ - **`<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.
861
+ - **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.
862
+ - **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`.
863
+ - **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.
864
+ - **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.
865
+ - **`data-if` is hidden by default**: state lives only in TS, and nothing is shown unless it says so.
866
+ - **`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.
867
+
868
+ ## Limitations
869
+
870
+ - 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).
871
+ - Event names for `data-on-*` must be lowercase; non-bubbling events handle only the target itself.
872
+ - 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.
873
+ - The `CustomEvent` received by an event handler method must be cast with `EmitEvent<child component, "event name">`, because HTML attributes cannot carry types.
874
+ - `props` can be passed through HTML attributes only when the default value is a string / number / boolean.
875
+ - Automatic binding covers only text holes and `data-if`; lists and attributes must be written in `render`.
876
+ - 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.
877
+ - No SSR or hydration.
878
+ - No attribute binding (deliberately; use `refs`).
879
+ - The unused-markup lint compares against the class body text, and is looser for names inside list items (it only looks at identifiers).
880
+ - `<style scoped>` falls back to CSS nesting in browsers that do not support `@scope`, which requires browser support for nesting (mainstream since 2023).
881
+ - ARIA checks cover only common items in static HTML (see the list above) and are not a complete accessibility audit.
882
+ - Browser tests are based on Chromium; Firefox 157 passed manual verification, and WebKit (Safari) is not verified.