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