@nativele/ts-dom 0.5.0 → 0.5.2

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.
@@ -0,0 +1,339 @@
1
+ ---
2
+ name: nativele-angular
3
+ description: "Use Nativele web components inside an existing Angular 22 app with standalone components: CUSTOM_ELEMENTS_SCHEMA, property and event bindings, change detection, content projection into slots, lifecycle, and generating the templates for the Angular CLI build. Use when a project depends on @angular/core and @nativele/ts-dom."
4
+ metadata:
5
+ tier: experimental
6
+ nativele: ">=0.5.1 <0.6.0"
7
+ verified: "2026-11-05"
8
+ tools: "@angular/core@22.2.4 @angular/compiler-cli@22.2.4 @angular/build@22.2.6 @angular/cli@22.2.6 typescript@6.0.3 vitest@5.1.0 jsdom@30.1.3"
9
+ author: "Nativele maintainers"
10
+ detects: "@angular/core"
11
+ ---
12
+
13
+ # Nativele + Angular
14
+
15
+ ## When to use
16
+
17
+ - The project is an Angular 22 app with standalone components (as made by `ng new`) and wants to use components written with Nativele (`@nativele/ts-dom`), for example widgets shared with non-Angular pages.
18
+ - Signals: `@angular/core` and `@nativele/ts-dom` in `package.json`, `angular.json`, Angular templates that use tags such as `<info-card>`.
19
+ - Not for writing the Nativele components themselves: use `nativele-core` (`npx ts-dom skills add nativele-core`).
20
+ - Not verified with NgModule-based apps, server-side rendering or hydration, or Angular versions other than 22.
21
+
22
+ Nativele components are native custom elements. Angular renders their tags like any other DOM element; nothing wraps them.
23
+
24
+ ## Versions
25
+
26
+ | Tool | Verified version |
27
+ |---|---|
28
+ | @nativele/ts-dom | 0.5.1 |
29
+ | @angular/core, @angular/common, @angular/compiler, @angular/compiler-cli, @angular/platform-browser, @angular/router | 22.2.4 |
30
+ | @angular/cli, @angular/build | 22.2.6 |
31
+ | typescript | 6.0.3 |
32
+ | vitest (through `ng test`) | 5.1.0 |
33
+ | jsdom | 30.1.3 |
34
+
35
+ Verified on 2026-11-05 in Google Chrome 148, against `ng serve` (development and production configurations), in an app made with `npx @angular/cli@latest new <name> --defaults --skip-git`. That app is zoneless (no `zone.js`); the same checks also passed with `zone.js` 0.16.3 and `provideZoneChangeDetection()`. With a different major version of Angular, treat every step as unverified.
36
+
37
+ ## Set-up
38
+
39
+ 1. Keep the Nativele components in their own folder, for example `src/nv/`, each as `x.html` plus `x.ts`. Write them with the core Skill: `npx ts-dom skills add nativele-core`.
40
+ 2. Install: `npm i @nativele/ts-dom`.
41
+ 3. Set `"strict": true` in `compilerOptions` of the root `tsconfig.json` (the Angular 22 CLI template leaves it out; `tsconfig.app.json` and `tsconfig.spec.json` extend the root, so both get it). Nativele's type checks depend on it. If turning it on for the whole app is not possible yet, set it in `tsconfig.app.json` only; `ts-dom doctor` then still warns about `tsconfig.spec.json`.
42
+ 4. Generate the template modules before every Angular command. The Angular CLI build (`@angular/build:application`) takes no Vite plugins from the project, so `tsDom()` cannot be added; run `ts-dom gen` from npm's `pre` scripts instead:
43
+
44
+ ```sh
45
+ npm pkg set scripts.prestart="ts-dom gen src/nv --whitespace=collapse" \
46
+ scripts.prebuild="ts-dom gen src/nv --whitespace=collapse" \
47
+ scripts.pretest="ts-dom gen src/nv --whitespace=collapse"
48
+ ```
49
+
50
+ `npm start`, `npm run build` and `npm test` then generate first (`npx ng ...` called directly does not). `--whitespace=collapse` is there because the CLI template sets up Prettier, and `ts-dom doctor` asks for it; doctor finds the flag in the `package.json` scripts. Collapsing also drops the whitespace between tags, so `textContent` reads `x1A1:new`, not `x1 A1:new`; write test expectations that way.
51
+
52
+ While `npm start` runs, keep a second terminal with `npx ts-dom gen src/nv --watch --whitespace=collapse`: editing a template or adding a component regenerates its module, and `ng serve` rebuilds and reloads the page.
53
+
54
+ 5. Pass the folder to every `ts-dom` command (`npx ts-dom check src/nv`). Without it the default is `src`, and `ts-dom` reads Angular's own templates (`src/app/*.html`) as Nativele templates and reports errors for them.
55
+ 6. Add the generated files to `.gitignore`:
56
+
57
+ ```gitignore
58
+ *.html.ts
59
+ ts-dom-tags.ts
60
+ ```
61
+
62
+ 7. Import every Nativele component module in `src/main.ts`, before `bootstrapApplication`:
63
+
64
+ ```ts
65
+ // src/main.ts
66
+ import "./nv/info-card.js";
67
+ import "./nv/shadow-card.js";
68
+ import "./nv/light-card.js";
69
+ import "./nv/live-badge.js";
70
+ import { bootstrapApplication } from '@angular/platform-browser';
71
+ import { appConfig } from './app/app.config';
72
+ import { App } from './app/app';
73
+
74
+ bootstrapApplication(App, appConfig)
75
+ .catch((err) => console.error(err));
76
+ ```
77
+
78
+ Angular sets `[prop]` bindings as properties even on an element that is not defined yet, and Nativele takes those values over when `define()` runs, so a late import loses no data; but until then the element is empty. Importing first makes the first render complete.
79
+
80
+ 8. Add `schemas: [CUSTOM_ELEMENTS_SCHEMA]` to every standalone component whose template uses a Nativele tag. Without it the build fails with `NG8001: 'info-card' is not a known element` and `NG8002: Can't bind to 'item' since it isn't a known property of 'info-card'`.
81
+
82
+ ## Example
83
+
84
+ The Nativele components, as in `nativele-core`:
85
+
86
+ ```html
87
+ <!-- src/nv/info-card.html -->
88
+ <span>{{ label }}</span>
89
+ <b>{{ count }}</b>
90
+ <i data-ref="itemName"></i>
91
+ <button type="button" data-on-click="pick">Pick</button>
92
+ ```
93
+
94
+ ```ts
95
+ // src/nv/info-card.ts
96
+ import { Component, define, event } from "@nativele/ts-dom";
97
+ import tpl from "./info-card.html.js";
98
+
99
+ export interface Item {
100
+ id: number;
101
+ name: string;
102
+ }
103
+
104
+ export class InfoCard extends Component(tpl, {
105
+ props: { label: "", count: 0, item: null as Item | null },
106
+ emits: { "item-select": event<{ id: number }>() },
107
+ }) {
108
+ pick(): void {
109
+ const item = this.props.item;
110
+ if (item) this.emit("item-select", { id: item.id });
111
+ }
112
+ override render(view: { item: Item | null }): void {
113
+ this.refs.itemName.textContent = view.item ? view.item.name : "-";
114
+ }
115
+ }
116
+ define("info-card", InfoCard);
117
+ ```
118
+
119
+ ```html
120
+ <!-- src/nv/shadow-card.html and src/nv/light-card.html -->
121
+ <h2><slot name="title">Untitled</slot></h2>
122
+ <div><slot></slot></div>
123
+ ```
124
+
125
+ ```ts
126
+ // src/nv/shadow-card.ts
127
+ import { Component, define } from "@nativele/ts-dom";
128
+ import tpl from "./shadow-card.html.js";
129
+
130
+ export class ShadowCard extends Component(tpl, { shadow: true }) {}
131
+ define("shadow-card", ShadowCard);
132
+ ```
133
+
134
+ ```ts
135
+ // src/nv/light-card.ts
136
+ import { Component, define } from "@nativele/ts-dom";
137
+ import tpl from "./light-card.html.js";
138
+
139
+ export class LightCard extends Component(tpl) {}
140
+ define("light-card", LightCard);
141
+ ```
142
+
143
+ ```html
144
+ <!-- src/nv/live-badge.html -->
145
+ <span>{{ user }}</span>
146
+ ```
147
+
148
+ ```ts
149
+ // src/nv/session.ts
150
+ import { createStore } from "@nativele/ts-dom";
151
+
152
+ export const session = createStore({ user: "Ada" });
153
+ ```
154
+
155
+ ```ts
156
+ // src/nv/live-badge.ts
157
+ import { Component, define } from "@nativele/ts-dom";
158
+ import tpl from "./live-badge.html.js";
159
+ import { session } from "./session.js";
160
+
161
+ export class LiveBadge extends Component(tpl) {
162
+ override connected(): void {
163
+ this.bind(session, (s) => (this.text.user = s.user));
164
+ }
165
+ }
166
+ define("live-badge", LiveBadge);
167
+ ```
168
+
169
+ The Angular component that uses them:
170
+
171
+ ```ts
172
+ // src/app/app.ts
173
+ import { Component, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core';
174
+ import type { EmitEvent } from '@nativele/ts-dom';
175
+ import type { InfoCard, Item } from '../nv/info-card.js';
176
+ import { session } from '../nv/session.js';
177
+
178
+ const fruits: Item[] = [
179
+ { id: 1, name: 'apple' },
180
+ { id: 2, name: 'pear' },
181
+ { id: 3, name: 'plum' },
182
+ ];
183
+
184
+ @Component({
185
+ selector: 'app-root',
186
+ templateUrl: './app.html',
187
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
188
+ })
189
+ export class App {
190
+ protected readonly count = signal(0);
191
+ protected readonly item = signal<Item>(fruits[0]!);
192
+ protected readonly picked = signal<number[]>([]);
193
+ protected pickedPlain = '';
194
+ protected readonly fruits = signal(fruits);
195
+ protected readonly rows = signal(['a', 'b', 'c']);
196
+ protected readonly showTitle = signal(true);
197
+ protected readonly showBadge = signal(true);
198
+
199
+ select(e: Event): void {
200
+ const id = (e as EmitEvent<InfoCard, 'item-select'>).detail.id;
201
+ this.picked.update((p) => [...p, id]);
202
+ this.pickedPlain += id;
203
+ }
204
+ next(): void {
205
+ this.count.update((c) => c + 1);
206
+ this.item.set(fruits[1]!);
207
+ }
208
+ rotate(): void {
209
+ this.rows.update((r) => [r.at(-1)!, ...r.slice(0, -1)]);
210
+ this.fruits.update((f) => [f.at(-1)!, ...f.slice(0, -1)]);
211
+ }
212
+ toggleB(): void {
213
+ this.rows.update((r) => (r.includes('b') ? r.filter((x) => x !== 'b') : ['b', ...r]));
214
+ }
215
+ rename(): void {
216
+ session.set({ user: 'Grace' });
217
+ }
218
+ }
219
+ ```
220
+
221
+ ```html
222
+ <!-- src/app/app.html -->
223
+ <info-card id="card" label="Fruit" [count]="count()" [item]="item()" (item-select)="select($event)" />
224
+ <p id="picked">{{ picked().join(',') }}</p>
225
+ <p id="picked-plain">{{ pickedPlain }}</p>
226
+ <button type="button" id="next" (click)="next()">Next</button>
227
+
228
+ <ul id="cards">
229
+ @for (f of fruits(); track f.id) {
230
+ <li><info-card [label]="f.name" [item]="f" (item-select)="select($event)" /><live-badge /></li>
231
+ }
232
+ </ul>
233
+
234
+ <shadow-card id="shadow">
235
+ @if (showTitle()) {
236
+ <span slot="title">Shadow</span>
237
+ }
238
+ @for (r of rows(); track r) {
239
+ <p>{{ r }}</p>
240
+ }
241
+ </shadow-card>
242
+
243
+ <light-card id="light">
244
+ <div slot="title">
245
+ @if (showTitle()) {
246
+ <span>Light</span>
247
+ }
248
+ </div>
249
+ <div>
250
+ @for (r of rows(); track r) {
251
+ <p>{{ r }}</p>
252
+ }
253
+ </div>
254
+ </light-card>
255
+
256
+ @if (showBadge()) {
257
+ <live-badge id="badge" />
258
+ }
259
+
260
+ <button type="button" id="rotate" (click)="rotate()">Rotate</button>
261
+ <button type="button" id="toggle-b" (click)="toggleB()">Toggle b</button>
262
+ <button type="button" id="toggle-title" (click)="showTitle.set(!showTitle())">Toggle title</button>
263
+ <button type="button" id="toggle-badge" (click)="showBadge.set(!showBadge())">Toggle badge</button>
264
+ <button type="button" id="rename" (click)="rename()">Rename</button>
265
+ ```
266
+
267
+ - `[item]`, `[count]` and `[label]` are set as properties; the object is the same object the signal holds. A plain attribute (`label="Fruit"`) also fills a string prop.
268
+ - `(item-select)` listens to the exact, hyphenated event name on the element. The handler receives the `CustomEvent`; read its data through `EmitEvent<InfoCard, 'item-select'>`.
269
+ - A signal change re-renders the Nativele component in the same change detection pass. Changing state inside an `(item-select)` handler needs no extra call, in a zoneless app too, even for a plain field such as `pickedPlain`, because Angular runs change detection after every template listener.
270
+ - `@for` with `track` moves `<info-card>` hosts when the list is reordered; they keep their props and listeners.
271
+
272
+ ## Known limitations
273
+
274
+ 1. **`CUSTOM_ELEMENTS_SCHEMA` turns off binding checks for unknown elements.** With it, Angular's compiler accepts any property on any hyphenated tag: `[item]="42"`, `[count]="'x'"` and `[nope]="1"` on `<info-card>` all build without an error. Angular has no mechanism to type-check bindings on custom elements, so keep the values typed in the Angular class (`signal<Item>(...)`) and check the Nativele side with `ts-dom check src/nv`.
275
+ 2. **`$event` is typed `Event`.** Angular's template type checker types `$event` of a DOM event it does not know as `Event`, so `select($event.detail)` or a handler parameter typed `CustomEvent<...>` fails with `TS2339: Property 'detail' does not exist on type 'Event'` or `TS2345: Argument of type 'Event' is not assignable to parameter of type 'CustomEvent<...>'`. Take `Event` and cast it with `EmitEvent<Component, 'event-name'>` from `@nativele/ts-dom`; a wrong event name there becomes `never` and fails with `Property 'detail' does not exist on type 'never'`. Nothing checks that the cast names the element the listener is on.
276
+ 3. **Bind properties, not attributes, for objects.** `[attr.item]="item()"` writes the attribute `item="[object Object]"` and the prop stays `null`. `[attr.count]` works for a number prop (the attribute text is converted), but `[count]` is simpler.
277
+ 4. **Light DOM slots and Angular-managed children do not mix.** In the default Light DOM mode (`Component(tpl)` without `shadow`), Nativele moves the host's children into its slot positions. When `@if` or `@for` sit directly inside the tag, Angular still adds and removes nodes, but a node it re-inserts lands in the wrong place: a `<span slot="title">` brought back by `@if` ends up in the default slot while the title shows its fallback, and `assignedNodes()` stops matching the page. Angular reports no error. Use one of:
278
+ - `shadow: true` on the component; `@if` and `@for` children can then change freely;
279
+ - one stable element per slot that Angular never removes, with `@if`/`@for` inside it (the `light-card` above). A stable but empty wrapper still fills its slot, so the slot's fallback content does not show; when the fallback matters, use `shadow: true`.
280
+ 5. **Zoneless rendering is scheduled.** In the CLI's default zoneless app, Angular updates the DOM after a click in a later task, so code that calls `button.click()` and reads the DOM right away sees the old values; with `zone.js` the DOM is already updated when `click()` returns. A test that reads the DOM right after dispatching an event must wait (Playwright: wait for the expected text; TestBed: `await fixture.whenStable()`). The Nativele component itself renders synchronously as soon as Angular sets the property.
281
+ 6. **State changed outside Angular's listeners.** In a zoneless app, a plain field set from a Nativele store subscription (`session.subscribe(...)` in an Angular class) or a `setTimeout` does not re-render the Angular view; put the value in a signal. Nativele components that `bind` the store update themselves either way.
282
+ 7. **`ng serve` started before the modules were generated stays broken.** It fails with `Could not resolve "./info-card.html.js"` and does not rebuild when `ts-dom gen` creates the files later. Use `npm start` (step 4 runs `gen` first), or restart `ng serve`, or save one of the component `.ts` files.
283
+ 8. **Lifecycle.** `@if` removing a host runs `disconnected()` and ends its `bind` subscriptions; inserting it again runs `connected()` and subscribes again. `@for` reordering moves hosts, which runs `disconnected()` and `connected()` on them; `bind` keeps working after the move.
284
+ 9. **Unit tests.** `main.ts` is not loaded by `ng test`: import the Nativele modules at the top of each spec that renders their tags. `tsconfig.spec.json` includes only `*.spec.ts` and `*.d.ts`, so `HTMLElementTagNameMap['info-card']` is not known there; import the class type (`import type { InfoCard } from '../nv/info-card.js'`). Nativele components render in `ng test`'s jsdom environment.
285
+ 10. Not verified: NgModule-based apps, server-side rendering and hydration, Angular forms (`ngModel`, reactive forms) bound to Nativele components, `ng build --watch`, and Angular versions other than 22.
286
+
287
+ ## Verify
288
+
289
+ Run in the project:
290
+
291
+ ```sh
292
+ npx ts-dom gen src/nv --whitespace=collapse # writes the modules; prints nothing
293
+ npx ts-dom check src/nv --whitespace=collapse # expect: ts-dom check: no issues
294
+ npx ts-dom doctor src/nv # expect: ts-dom doctor: the set-up looks right (info lines are suggestions)
295
+ npx ngc -p tsconfig.app.json --noEmit # expect: no output, exit code 0
296
+ npm run build # expect: Application bundle generation complete.
297
+ npm test -- --watch=false # expect: the specs pass
298
+ ```
299
+
300
+ - `ts-dom check` runs `tsc` on the app's TypeScript through the references of the root `tsconfig.json`, so it reports a type error in `src/app/app.ts` too. It does not read Angular templates. `ngc` (or the build) is what type-checks the Angular templates, including the `EmitEvent` casts.
301
+ - Run `ts-dom check` with `--whitespace=collapse` or after `gen`: when the generated files are missing, `check` writes them with whitespace kept as written; once they exist, it keeps the mode they were written with.
302
+
303
+ In the browser, against `npm start` (the development server shows Angular's warnings in the console), check:
304
+
305
+ - the element has no attribute for the object prop (`el.getAttributeNames()`) and `el.item` is the object;
306
+ - a click on the component's button runs the `(item-select)` handler once and updates the Angular view;
307
+ - after `@for` reorders hosts, each keeps its own `item`, and its events still fire;
308
+ - removing a host with `@if` ends its `bind` subscription: keep a reference to the removed element, change the store, and its text stays old;
309
+ - children that `@if`/`@for` toggle, reorder, add and remove stay in the right slot; read a slot with `el.assignedNodes(name)` (`""` is the default slot), which works in both modes, and compare elements, since Angular leaves comment anchors among the children;
310
+ - no errors or warnings in the console.
311
+
312
+ With Playwright, wait for each expected change instead of reading the DOM right after a click (limitation 5).
313
+
314
+ ## Troubleshooting
315
+
316
+ | Symptom | Cause | Fix |
317
+ |---|---|---|
318
+ | `NG8001: 'info-card' is not a known element` | the standalone component has no `CUSTOM_ELEMENTS_SCHEMA` | add `schemas: [CUSTOM_ELEMENTS_SCHEMA]` to that component |
319
+ | `NG8002: Can't bind to 'item' since it isn't a known property of 'info-card'` | the same | the same |
320
+ | `TS2339: Property 'detail' does not exist on type 'Event'`, or `Argument of type 'Event' is not assignable to parameter of type 'CustomEvent<...>'` | Angular types `$event` of a custom event as `Event` | take `Event` in the handler and cast with `EmitEvent<Component, 'event-name'>` |
321
+ | `Property 'detail' does not exist on type 'never'` | `EmitEvent` names an event the component does not emit | use the exact name from the component's `emits` |
322
+ | `Could not resolve "./info-card.html.js"` from `ng build` or `ng serve` | the template modules were not generated | run `ts-dom gen src/nv --whitespace=collapse`; use `npm start`/`npm run build`, which run it first (step 4); restart a running `ng serve` |
323
+ | An object prop is `null`, and the element has `item="[object Object]"` | `[attr.item]` was used | bind the property: `[item]` |
324
+ | The element stays empty | its module is not imported | import it in `src/main.ts` (step 7) |
325
+ | A slotted child shows in the wrong slot or the slot fallback stays visible | `@if`/`@for` directly inside a Light DOM component with slots | `shadow: true`, or a stable element per slot |
326
+ | `ts-dom check` reports `invalid-hole` or `no-importer` in `src/app/app.html` | the command ran without a folder and read Angular templates | pass the Nativele folder: `ts-dom check src/nv` |
327
+ | `ts-dom doctor` warns `tsconfig-not-strict` | the Angular 22 CLI template does not set `strict` | step 3 |
328
+ | `ts-dom doctor` warns `formatter-whitespace` | Prettier is set up and no script generates with `--whitespace=collapse` | step 4 |
329
+ | `Property 'info-card' does not exist on type 'HTMLElementTagNameMap'` in a spec | `tsconfig.spec.json` does not include `ts-dom-tags.ts` | import the component class type instead |
330
+ | The Angular view does not update after a store change or a timer | zoneless change detection does not run for plain fields changed outside template listeners | keep that value in a signal |
331
+
332
+ ## See also
333
+
334
+ - Components, props, events and `EmitEvent`: https://github.com/Pixmi/nativele/blob/main/docs/components.md#data-flow-props-down-events-up
335
+ - Slots, Light DOM and Shadow DOM: https://github.com/Pixmi/nativele/blob/main/docs/components.md#light-default-and-shadow
336
+ - Lifecycle and `bind`: https://github.com/Pixmi/nativele/blob/main/docs/components.md#components
337
+ - Stores: https://github.com/Pixmi/nativele/blob/main/docs/components.md#state-store-when-not-using-state
338
+ - Setup, `gen --watch`, whitespace and checks: https://github.com/Pixmi/nativele/blob/main/docs/tooling.md
339
+ - A framework integration with typed templates: `npx ts-dom skills nativele-react`
@@ -30,10 +30,92 @@ Verified on 2026-11-05 by the library's own CI, which type-checks, lints and run
30
30
 
31
31
  ## Set-up
32
32
 
33
- 1. `npm i @nativele/ts-dom` and `npm i -D typescript`.
34
- 2. `tsconfig.json` with `"strict": true`, `"lib"` including `"DOM"`, and `"moduleResolution"` `"NodeNext"` or `"bundler"`; its `include` must cover the folders with templates.
35
- 3. With Vite, add `tsDom()` from `@nativele/ts-dom/vite` to `plugins` and set `"type": "module"` in `package.json`. Without a bundler, `ts-dom gen` plus `tsc` is enough.
36
- 4. Run `npx ts-dom doctor` once; fix every error and warning it reports. Its `info` lines name the other Skills that fit the project.
33
+ 1. `npm i @nativele/ts-dom` and `npm i -D typescript`, and set `"type": "module"` in `package.json` (with or without a bundler).
34
+ 2. Put the components under `src/`, the folder every `ts-dom` command scans by default; for another folder, pass it to each command (`npx ts-dom check web/ui`).
35
+ 3. `tsconfig.json` with `"strict": true`, `"lib"` including `"DOM"`, and `"moduleResolution"` `"NodeNext"` or `"bundler"`; its `include` must cover the folders with templates. A complete one for the no-bundler case is in the setup guide: https://github.com/Pixmi/nativele/blob/main/docs/tooling.md#installation-and-setup
36
+ 4. With Vite, add `tsDom()` from `@nativele/ts-dom/vite` to `plugins`. Without a bundler, `ts-dom gen` plus `tsc` compiles the code, and the page needs an import map for `@nativele/ts-dom` plus a `<script type="module">` for the compiled entry, a module that imports the top-level components: https://github.com/Pixmi/nativele/blob/main/docs/tooling.md#running-in-the-browser-no-bundler
37
+ 5. Add `*.html.ts` and `ts-dom-tags.ts` to `.gitignore`: they are generated.
38
+ 6. Run `npx ts-dom doctor` once; fix every error and warning it reports. Its `info` lines name the other Skills that fit the project.
39
+
40
+ ## Example
41
+
42
+ A list whose items are components, with a store, a derived value and events from the items:
43
+
44
+ ```html
45
+ <!-- src/todo-item.html -->
46
+ <input type="checkbox" data-ref="check" data-on-change="onToggle" />
47
+ <span>{{ text }}</span>
48
+ <button type="button" data-on-click="onRemove">remove</button>
49
+ ```
50
+
51
+ ```ts
52
+ // src/todo-item.ts
53
+ import { Component, define, event } from "@nativele/ts-dom";
54
+ import tpl from "./todo-item.html.js";
55
+ import type { Todo } from "./store.js";
56
+
57
+ export class TodoItem extends Component(tpl, {
58
+ props: { todo: { id: 0, title: "", done: false } as Todo },
59
+ derive: { text: (v) => v.todo.title },
60
+ emits: { toggled: event(), removed: event() },
61
+ }) {
62
+ onToggle(): void {
63
+ this.emit("toggled");
64
+ }
65
+ onRemove(): void {
66
+ this.emit("removed");
67
+ }
68
+ override render(view: { todo: Todo }, changed: ReadonlySet<"todo">): void {
69
+ if (changed.has("todo")) this.refs.check.checked = view.todo.done;
70
+ }
71
+ }
72
+ define("todo-item", TodoItem);
73
+ ```
74
+
75
+ ```html
76
+ <!-- src/todo-app.html -->
77
+ <p data-if="empty">Nothing to do</p>
78
+ <ul>
79
+ <template data-each="items" data-key="id" data-prop="todo">
80
+ <todo-item role="listitem" data-on-toggled="toggle" data-on-removed="drop"></todo-item>
81
+ </template>
82
+ </ul>
83
+ <p>{{ remaining }} left</p>
84
+ ```
85
+
86
+ ```ts
87
+ // src/todo-app.ts
88
+ import { Component, define, type EventHit } from "@nativele/ts-dom";
89
+ import tpl from "./todo-app.html.js";
90
+ import { todos, type Todo } from "./store.js"; // export const todos = createStore<readonly Todo[]>([]);
91
+ import "./todo-item.js";
92
+
93
+ export class TodoApp extends Component(tpl, {
94
+ state: { items: [] as readonly Todo[] },
95
+ derive: {
96
+ remaining: (v) => v.items.filter((t) => !t.done).length,
97
+ empty: (v) => v.items.length === 0,
98
+ },
99
+ }) {
100
+ override connected(): void {
101
+ this.bind(todos, (items) => this.set({ items }));
102
+ }
103
+ toggle(_e: Event, hit: EventHit<Todo, "id">): void {
104
+ todos.set((s) => s.map((t) => (t.id === hit.key ? { ...t, done: !t.done } : t)));
105
+ }
106
+ drop(_e: Event, hit: EventHit<Todo, "id">): void {
107
+ todos.set((s) => s.filter((t) => t.id !== hit.key));
108
+ }
109
+ override render(view: { items: readonly Todo[] }, changed: ReadonlySet<"items">): void {
110
+ if (changed.has("items")) this.lists.items.items = view.items;
111
+ }
112
+ }
113
+ define("todo-app", TodoApp);
114
+ ```
115
+
116
+ - A `derive` function takes one argument: the state and props merged into one read-only object (other derived values are not in it).
117
+ - `emits` declares each event with `event()`, or `event<Detail>()` when it carries data; send the data with `this.emit("name", detail)`. The parent listens with `data-on-<event>` on the child tag and reads the data as `(e as EmitEvent<Child, "name">).detail`, because an HTML attribute cannot carry the event's type.
118
+ - `bind` goes in `connected()`; the subscription is released when the element is removed.
37
119
 
38
120
  ## The loop
39
121
 
@@ -47,14 +129,15 @@ Verified on 2026-11-05 by the library's own CI, which type-checks, lints and run
47
129
 
48
130
  - No expressions in HTML: `{{ name }}` is a single identifier. Compute in `derive` (or in TypeScript) and fill a named hole.
49
131
  - Change state only with `this.set()`. Rendering is synchronous; `this.batch()` combines updates.
50
- - Once a component has `state`, `props` or `derive`, every `{{ hole }}` and `data-if` must name one of them. Show store data by copying it into state in the `bind` callback.
132
+ - Once a component has `state`, `props` or `derive`, every `{{ hole }}` and `data-if` outside a `<template data-each>` must name one of them (holes inside a plain-element list are filled by `replace`). Show store data by copying it into state in the `bind` callback.
51
133
  - `store.set` replaces the whole value; a component's `this.set` merges. Spread the rest yourself: `store.set((s) => ({ ...s, status: "loading" }))`.
52
134
  - Lists, attributes and focus go in `render(view, changed)`. `changed` holds the state, prop and derived names that changed; annotate it with the names you check (`changed: ReadonlySet<"items" | "visible">`), never `ReadonlySet<string>`.
53
135
  - Props go down (the parent sets them), events go up (`emit`, handled with `data-on-<event>` on the child tag). `emit` does not bubble; data needed several levels away goes in a shared `createStore` that the components `bind`.
54
136
  - State, props and the view are deeply read-only: replace objects instead of changing them (`{ ...todo, done: true }`).
55
137
  - A `data-each` list cannot be nested: the inner list belongs to its own item component.
56
- - 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 = ...` in `render`, and type item handlers as `hit: EventHit<Item, "id">`.
57
- - Do not name a handler or prop like a DOM or Nativele member (`remove`, `click`, `title`, `render`, ...); `ts-dom check` reports it.
138
+ - A list of plain elements (no item component) has neither `data-key` nor `data-prop`; fill it with `this.lists.x.replace(items, key, fill)` in `render`: https://github.com/Pixmi/nativele/blob/main/docs/lists.md
139
+ - 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 = ...` in `render`, and type item handlers as `hit: EventHit<Item, "id">`. Each item component gets its item through that one prop; to pass more (selected, leading), map the items to objects that carry it before assigning.
140
+ - Do not name a handler or prop like a DOM or Nativele member (`remove`, `click`, `title`, `text`, `refs`, `state`, `render`, ...), or an emitted event like a native event (`click`, `change`, `input`, ...); `ts-dom check` reports both.
58
141
 
59
142
  ## Known limitations
60
143
 
@@ -77,6 +160,8 @@ Then exercise the changed component in a real browser (the project's own test ru
77
160
 
78
161
  | Symptom | Cause | Fix |
79
162
  |---|---|---|
163
+ | `ReferenceError: exports is not defined` in the browser | `package.json` has no `"type": "module"`, so `tsc` emitted CommonJS | set `"type": "module"`, delete the output folder, run `tsc` again |
164
+ | `Failed to resolve module specifier "@nativele/ts-dom"` | no bundler and no import map | add the import map from the setup guide before the module script |
80
165
  | `Cannot find module './x.html.js'` | the generated file is missing | run `npx ts-dom check` (or `ts-dom gen`) |
81
166
  | `missing-binding` at a template line | a hole or `data-if` name that state, props and derive do not have | add the field, or rename the hole |
82
167
  | A list stays empty | `render` never assigns it, or guards it with a name that is not in `changed` | assign `this.lists.x.items` in `render`, guarded by the name of what it shows |