@nativele/ts-dom 0.5.5 → 0.6.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +5 -0
  3. package/README.zh-TW.md +5 -0
  4. package/dist/{a11y-CiEJj_e6.mjs → a11y-KXbo-8o3.mjs} +119 -27
  5. package/dist/cli.mjs +273 -50
  6. package/dist/{lint-Did4w-NL.mjs → lint-DUsdDKZ-.mjs} +33 -8
  7. package/dist/oxlint-plugin.mjs +3 -3
  8. package/dist/vite-plugin.mjs +1 -1
  9. package/docs/architecture.md +188 -0
  10. package/docs/components.md +367 -0
  11. package/docs/contributing.md +80 -0
  12. package/docs/design.md +55 -0
  13. package/docs/lists.md +150 -0
  14. package/docs/performance.md +24 -0
  15. package/docs/recipes.md +189 -0
  16. package/docs/templates.md +73 -0
  17. package/docs/tooling.md +251 -0
  18. package/llms.txt +5 -5
  19. package/package.json +2 -1
  20. package/skills/README.md +19 -2
  21. package/skills/nativele-angular/SKILL.md +15 -11
  22. package/skills/nativele-architecture/SKILL.md +14 -11
  23. package/skills/nativele-ci/SKILL.md +148 -0
  24. package/skills/nativele-core/SKILL.md +16 -13
  25. package/skills/nativele-formatting/SKILL.md +167 -0
  26. package/skills/nativele-integration/SKILL.md +11 -8
  27. package/skills/nativele-linting/SKILL.md +174 -0
  28. package/skills/nativele-monorepo/SKILL.md +298 -0
  29. package/skills/nativele-react/SKILL.md +12 -9
  30. package/skills/nativele-state-management/SKILL.md +4 -4
  31. package/skills/nativele-svelte/SKILL.md +10 -8
  32. package/skills/nativele-tailwind/SKILL.md +8 -7
  33. package/skills/nativele-tanstack-query/SKILL.md +5 -5
  34. package/skills/nativele-testing/SKILL.md +19 -13
  35. package/skills/nativele-typescript/SKILL.md +186 -0
  36. package/skills/nativele-vite/SKILL.md +18 -14
  37. package/skills/nativele-vue/SKILL.md +9 -7
  38. package/skills/nativele-zod/SKILL.md +6 -6
@@ -0,0 +1,367 @@
1
+ # Components and data flow
2
+
3
+ Part of the [Nativele](../README.md) documentation.
4
+
5
+ ## Example: Parent and child components (props down, events up)
6
+
7
+ 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:
8
+
9
+ ```html
10
+ <button type="button" data-on-click="dec">-</button>
11
+ <output>{{ value }}</output>
12
+ <button type="button" data-on-click="inc">+</button>
13
+ ```
14
+
15
+ ```ts
16
+ import { Component, define, event } from "@nativele/ts-dom";
17
+ import tpl from "./quantity-input.html.js";
18
+
19
+ /** A child component: the parent owns the value (a prop); the child only asks for a change through an event. */
20
+ export class QuantityInput extends Component(tpl, {
21
+ props: { value: 0, min: 0, max: 10 },
22
+ emits: { "quantity-change": event<{ value: number }>() },
23
+ }) {
24
+ dec(): void {
25
+ if (this.props.value > this.props.min) this.emit("quantity-change", { value: this.props.value - 1 });
26
+ }
27
+ inc(): void {
28
+ if (this.props.value < this.props.max) this.emit("quantity-change", { value: this.props.value + 1 });
29
+ }
30
+ }
31
+ define("quantity-input", QuantityInput);
32
+ ```
33
+
34
+ The parent uses the child's tag directly in its own HTML. `ts-dom gen` automatically `import`s the child component and gives `this.refs.qty` the child's full type:
35
+
36
+ ```html
37
+ <quantity-input data-ref="qty" min="1" max="5" data-on-quantity-change="onQuantity"></quantity-input>
38
+ <p>Total: {{ total }}</p>
39
+ ```
40
+
41
+ ```ts
42
+ import { Component, define } from "@nativele/ts-dom";
43
+ import type { EmitEvent } from "@nativele/ts-dom";
44
+ import type { QuantityInput } from "./quantity-input.js";
45
+ import tpl from "./order-line.html.js";
46
+
47
+ export class OrderLine extends Component(tpl, {
48
+ state: { quantity: 1, price: 20 },
49
+ derive: { total: (s) => s.quantity * s.price },
50
+ }) {
51
+ // Event up: the child asked for a new quantity; the parent decides.
52
+ onQuantity(e: Event): void {
53
+ this.set({ quantity: (e as EmitEvent<QuantityInput, "quantity-change">).detail.value });
54
+ }
55
+
56
+ // Data down: the child's props are typed (a wrong name or type is a tsc error).
57
+ override render(view: { quantity: number }): void {
58
+ this.refs.qty.value = view.quantity;
59
+ }
60
+ }
61
+ define("order-line", OrderLine);
62
+ ```
63
+
64
+ 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.
65
+
66
+ ## Components
67
+
68
+ ```ts
69
+ class MyEl extends Component(tpl) {} // default: Light DOM
70
+ class Iso extends Component(tpl, { shadow: true }) {} // optional: Shadow DOM
71
+ ```
72
+
73
+ Provided members:
74
+
75
+ | Member | Description |
76
+ |---|---|
77
+ | `refs` | The elements corresponding to `data-ref` |
78
+ | `text` | Holes; assigning a value updates every place where the hole appears |
79
+ | `lists` | List objects |
80
+ | `show(name, on)` | Toggle a `data-if` (hidden by default) |
81
+ | `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](#data-flow-props-down-events-up) |
82
+ | `slotchange(name)` | Override to react to later changes of slot content in Light mode |
83
+ | `assignedNodes(name)` | The nodes currently assigned to that slot (excluding fallback content); behaves the same in Light and Shadow |
84
+ | `bind(store, fn)` | Subscribe to a store, call once immediately, and unsubscribe automatically when the element is removed |
85
+ | `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` |
86
+ | `onCleanup(fn)` | Register a cleanup function to run when the element is removed |
87
+ | `root` | The element itself (default), or the `ShadowRoot` (`shadow: true`) |
88
+ | `connected()` / `disconnected()` | Override to attach your own behavior |
89
+
90
+ The lifecycle is the Custom Elements `connectedCallback` / `disconnectedCallback`; you can also override them directly (remember to call `super`). Children a framework or your code adds after the element is connected are placed in their slot in a microtask (a `MutationObserver` in Light mode, native `slotchange` in Shadow mode), so read `assignedNodes` after `await Promise.resolve()`.
91
+
92
+ A Light DOM component builds its content when it is connected (while the document is still being parsed, it waits for `DOMContentLoaded` so that its children have arrived); a `shadow: true` component builds it into its shadow root in the constructor. So an element made with `document.createElement` in Light mode has no children, and its `refs` and `text` are empty, until it is appended to the document; set props before that, and read the DOM after it.
93
+
94
+ `define(name, Class)` registers the element once: calling it again with a name that is already defined does nothing and keeps the first class (`customElements.define` itself would throw). A test that re-imports a component module therefore keeps the first class.
95
+
96
+ ### Light (default) and Shadow
97
+
98
+ 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.
99
+
100
+ | | Light (default) | `shadow: true` |
101
+ |---|---|---|
102
+ | Render location | The element itself | The element's open shadow root |
103
+ | 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 |
104
+ | `<style>` | Extracted and injected into `document.head` only once | Stays inside the shadow root, not injected into `<head>`; the `scoped` attribute has no effect |
105
+ | `<slot>` | Moved once at creation, then maintained by a `MutationObserver` | Native to the browser |
106
+ | Slot change notification | Override `slotchange(name)` | **Native `slotchange` event** (`slot.addEventListener("slotchange", …)`); the `slotchange(name)` method is only called in Light mode |
107
+ | `assignedNodes(name)` | Yes | Yes (equivalent to native `slot.assignedNodes()`) |
108
+ | `state`, `props`, `derive`, `emit`, `data-on-*`, lists, `data-if`, attributes | All supported | All supported, with the same behavior |
109
+ | Forms | Naturally compatible with outer forms | You must handle `ElementInternals` yourself |
110
+
111
+ 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-*`.
112
+
113
+ Light-mode slot rules:
114
+
115
+ - 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.
116
+ - `{{ }}`, `data-ref`, and `data-if` in user content are not parsed; they belong to the user's own DOM.
117
+ - 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).
118
+ - The rendered Light DOM has no `<slot>` elements: each slot position is the range between the comments `<!--slot:name-->` and `<!--/slot:name-->` (`<!--slot:-->` for the default slot). Read what a slot holds with `assignedNodes(name)` (`assignedNodes("")` for the default slot) rather than querying for `slot`.
119
+ - Dynamic changes rely on a `MutationObserver`, which observes only while the element is connected and is disconnected on removal.
120
+
121
+ ```ts
122
+ class Card extends Component(tpl) {
123
+ override slotchange(name: "head" | "") { console.log("slot changed", name); }
124
+ }
125
+ ```
126
+
127
+ ### Slots and children managed by a framework
128
+
129
+ In Light mode the host's children are moved into the slot positions inside the template, so they no longer sit where the framework that rendered them put them. When a framework (React, Vue, Svelte, Angular, Preact, ...) later inserts, removes or reorders those children, it works against nodes that have moved: depending on the framework, the update throws a DOM error, or a node it adds back lands next to an anchor that now sits in another slot, so it shows in the wrong slot and `assignedNodes()` no longer matches the page. Use one of:
130
+
131
+ - `shadow: true` on the component: the browser assigns slots natively, and the children can change freely.
132
+ - One stable element per slot that the framework never removes, with the changing content inside it (`<div slot="title">...</div>`, and a plain `<div>` for the default slot). An empty wrapper still fills its slot, so the slot's fallback content does not show; when the fallback matters, use `shadow: true`.
133
+
134
+ ### Styles
135
+
136
+ A top-level `<style>` in a template can be written in two ways in Light mode:
137
+
138
+ ```html
139
+ <style>
140
+ user-card .tag { color: gray } /* global: shared by the whole document */
141
+ </style>
142
+ <style scoped>
143
+ :scope { display: block } /* scoped: only applies inside <user-card> */
144
+ .tag { color: gray }
145
+ </style>
146
+ ```
147
+
148
+ - `<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.
149
+ - Both are injected into `document.head` only once and are not duplicated per instance.
150
+ - 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.
151
+ - In Light mode `:host` has no effect, and a warning is logged to the console at runtime; use a tag selector or `:scope` instead.
152
+ - With `shadow: true`, `<style>` stays inside the shadow root and the `scoped` attribute has no effect.
153
+ - The injected `<style>` is not in a cascade layer, so it wins over styles in a layer (a CSS framework's utilities, for example) whatever their specificity. To let layered styles override a template's defaults, put the template's rules in a layer: `<style scoped> @layer components { .tag { color: gray } } </style>`.
154
+ - Document styles do not reach a `shadow: true` component. To use a stylesheet there, adopt it in the constructor after `super()`: `this.shadowRoot!.adoptedStyleSheets = [sheet]`, with one `CSSStyleSheet` shared by every instance.
155
+
156
+ ## Data and View: state (optional)
157
+
158
+ The simplest usage: give `Component` a `state`, and change data only with `this.set()`.
159
+
160
+ ```ts
161
+ class Counter extends Component(tpl, {
162
+ state: { title: "Todo", items: [] as Item[], filter: "all" },
163
+ derive: { remaining: (s) => s.items.filter((i) => !i.done).length }, // derived value
164
+ }) {
165
+ add(e: Event) {
166
+ this.set((s) => ({ items: [...s.items, { id: nextId++, title: "x", done: false }] }));
167
+ }
168
+ override render(view, changed) {
169
+ // whatever automatic binding cannot handle (lists, attributes, focus…) goes here
170
+ if (changed.has("items")) this.lists.items.replace(view.items, (i) => i.id, (i, { text }) => (text.title = i.title));
171
+ }
172
+ }
173
+ ```
174
+
175
+ | Member | Description |
176
+ |---|---|
177
+ | `state` | The current data, read-only. Use `set` to change data |
178
+ | `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` |
179
+ | `render(view, changed)` | Override it to handle updates other than automatic binding. `view` is `state`, props and the derived values, and `changed` holds the names of the state fields, props and derived values that changed this time (all of them on the first render). Use `changed.has("x")` to skip the parts that did not change, and annotate it with the names you check, for example `changed: ReadonlySet<"items" | "visible">`, so tsc reports a misspelled name or one that is not state, a prop or a derived value |
180
+ | `batch(fn)` | Combine multiple `set` calls: after `fn` finishes, the update runs once and `render` is called once |
181
+ | `derive` option | Derived values, usable directly by `{{ }}`, `data-if`, and `render`. They are recomputed after every state change, with **no dependency tracking** |
182
+
183
+ **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`.
184
+
185
+ Design trade-offs:
186
+
187
+ - **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.
188
+ - **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](lists.md#lists)).
189
+ - `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.
190
+ - When `state` is not used, the original `refs`, `text`, `show`, `bind`, and `createStore` work as before, fully backward compatible.
191
+ - Once a component has any of `state`, `props` or `derive`, every `{{ hole }}` and `data-if` in its template, outside a `<template data-each>`, must name one of them; tsc reports any other name. Holes inside a list item belong to the item and are filled by `replace` (see [Lists](lists.md)). To show data from a store in such a component, copy it into state in the `bind` callback (see [Recipes](recipes.md#showing-data-from-a-store)).
192
+
193
+ **Read-only all the way down**: `state`, `props`, the `view` passed to `render` and the argument of each `derive` function are typed `DeepReadonly`: arrays and objects inside them cannot be changed in place. `this.state.items[0].done = true` or `this.state.items.push(x)` is a `tsc` error, because an object changed in place keeps its identity and the list and child props, which compare by reference, would not update. Pass new objects instead: `this.set((s) => ({ items: s.items.map((i) => (i.id === id ? { ...i, done: true } : i)) }))`. When you annotate `render`'s `view` or a helper that takes the state, use read-only types (`readonly Item[]`, or `DeepReadonly<State>` from `@nativele/ts-dom`).
194
+
195
+ ### Common DOM operations in render
196
+
197
+ `render` uses the DOM directly; the library adds no wrapper for attributes or classes, because the native APIs are already typed through `refs`:
198
+
199
+ | To change | Write |
200
+ |---|---|
201
+ | Text | a `{{ hole }}` bound to state, or `this.text.name = value` |
202
+ | Whether an element is in the document | `data-if` bound to state, or `this.show("name", on)` (the element is removed, so its inner state is lost) |
203
+ | Whether an element shows, keeping its state (typed input, scroll position, playing media) | `this.refs.panel.hidden = !open` |
204
+ | An attribute | `this.refs.link.setAttribute("aria-current", "page")` or a typed property such as `this.refs.submit.disabled = view.loading` |
205
+ | A class | `this.refs.panel.classList.toggle("loading", view.loading)` |
206
+ | A form control's value | `if (this.refs.name.value !== view.name) this.refs.name.value = view.name` (see below) |
207
+ | A list | `this.lists.x.items = view.x` for components, `this.lists.x.replace(...)` for plain elements (see [Lists](lists.md)) |
208
+
209
+ Guard each block with `changed.has(...)` so a change elsewhere does not redo it.
210
+
211
+ ## Events: data-on-*
212
+
213
+ HTML writes only the event name and the method name; the logic is in TS:
214
+
215
+ ```html
216
+ <form data-on-submit="add">…</form>
217
+ <template data-each="items"><li><button data-on-click="removeItem">✕</button></li></template>
218
+ ```
219
+
220
+ ```ts
221
+ add(e: Event) { e.preventDefault(); … }
222
+ removeItem(e: Event, hit: EventHit) { this.set((s) => ({ items: s.items.filter((i) => i.id !== hit.key) })); }
223
+ ```
224
+
225
+ - 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.key` and `hit.item` are untyped and optional unless the handler names the item type and its key field, e.g. `hit: EventHit<Todo, "id">`, which makes `hit.item` a `Todo` and `hit.key` a `Todo["id"]` (use that form only for handlers inside list items). If you need extra parameters, put them in `data-*` attributes and read `hit.el.dataset` in the method.
226
+ - **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.
227
+ - **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`.
228
+ - 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.
229
+ - **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.
230
+ - Event names must be lowercase (HTML attributes are case-insensitive); for mixed-case events such as `DOMContentLoaded`, use `this.on()` instead.
231
+
232
+ ## Composition: Using Multiple Components Together
233
+
234
+ Each `.html` plus its TS is one component (a custom element), and writing its tag in another template is composition:
235
+
236
+ ```html
237
+ <!-- signup-form.html -->
238
+ <form-field data-ref="name" label="Name" data-on-field-input="onName"></form-field>
239
+ <template data-each="entries">
240
+ <li><entry-row data-ref="row" data-on-remove-entry="removeEntry"></entry-row></li>
241
+ </template>
242
+ ```
243
+
244
+ ```ts
245
+ render(v) {
246
+ this.refs.name.value = v.name; // data down: the child's props, fully typed
247
+ this.lists.entries.replace(v.entries, (x) => x.id, (x, { refs }) => { refs.row.name = x.name; });
248
+ }
249
+ onName(e: Event) { // event up: sent only to the direct parent of this tag
250
+ this.set({ name: (e as EmitEvent<FormField, "field-input">).detail.value });
251
+ }
252
+ ```
253
+
254
+ Composition needs three things, and ts-dom handles all of them:
255
+
256
+ - **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.
257
+ - **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. Like `.html.ts`, it is generated by `gen`: add both `*.html.ts` and `ts-dom-tags.ts` to `.gitignore`.
258
+ - **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.
259
+
260
+ "Template fragment include" is not provided: it would become a template-language feature. To share a piece of structure, make it a component.
261
+
262
+ ## Data Flow: props Down, Events Up
263
+
264
+ `props` and `emits` are two options besides `state` that give the data flow between parent and child components clear rules:
265
+
266
+ ```ts
267
+ class FormField extends Component(tpl, {
268
+ props: { label: "", value: "", error: "" }, // given by the parent (read-only)
269
+ state: { touched: false }, // owned by itself
270
+ derive: { showError: (s) => s.touched && s.error !== "" },
271
+ emits: { "field-input": event<{ value: string }>() }, // events emitted upward
272
+ }) {
273
+ onInput() {
274
+ this.set({ touched: true });
275
+ this.emit("field-input", { value: this.refs.input.value });
276
+ }
277
+ }
278
+ ```
279
+
280
+ | | Owner | Who can change it | How |
281
+ |---|---|---|---|
282
+ | `state` | The component itself | The component itself | `this.set()` |
283
+ | `props` | The parent | The parent | HTML attribute (`user-name="Ada"`) or JS property (`el.userName = "Ada"`) |
284
+ | Events | Emitted by the component itself | Anyone can subscribe | `this.emit(name, detail)` |
285
+
286
+ - 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`.
287
+ - 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.
288
+ - 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.
289
+ - The event's data type is obtained from `EmitEvent<FormField, "field-input">` (`CustomEvent<{ value: string }>`). A name the component does not declare resolves to `never`, so reading `.detail` fails with `Property 'detail' does not exist on type 'never'`.
290
+
291
+ ### Element types for framework declarations
292
+
293
+ A framework's template type checker (TSX, `vue-tsc`, `svelte-check`) learns the Nativele tags from a declaration built on the element types that `ts-dom-tags.ts` registers in `HTMLElementTagNameMap`. Two members of a component that uses `props` or `emits` carry what such a declaration needs:
294
+
295
+ | Member | Type |
296
+ |---|---|
297
+ | `props` | The props, read-only: `FormField["props"]` is `{ readonly label: string; readonly value: string; readonly error: string }`, and its keys are the prop names |
298
+ | `"~emits"` | Optional and type-only (it does not exist at runtime): maps each event name declared in `emits` to its `detail` type, `NonNullable<FormField["~emits"]>` is `{ "field-input": { value: string } }` |
299
+
300
+ ### Safeguards in Types and at Runtime
301
+
302
+ | Problem | Safeguard |
303
+ |---|---|
304
+ | Modifying a prop with `set()` inside the component | The type of `set` does not include props; a clear error is thrown at runtime |
305
+ | `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 |
306
+ | `state` / `props` / `derive` with the same name | tsc reports an error (`conflictingKeys`); an error is thrown when `Component()` is called |
307
+ | 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 |
308
+ | `emits` with the same name as a native event (`click`, `input`…) | tsc reports an error (`nativeEventNames`); an error is thrown at runtime |
309
+ | The parent gives the child a prop with a wrong name or type | tsc reports an error (types come from `HTMLElementTagNameMap`) |
310
+
311
+ ### Avoiding Layer-by-Layer Passing (lint: `ts-dom/data-flow`)
312
+
313
+ 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. How an app decides where each piece of state lives is in [Architecture](architecture.md#where-state-lives). Besides "events do not bubble by default", there are lint hints here (suggestions only, not errors):
314
+
315
+ | Id | Detection |
316
+ |---|---|
317
+ | `prop-forwarding` | Assigning your own prop as-is to a child component (`this.refs.kid.name = v.name`, where `name` is your own prop) |
318
+ | `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(...)`) |
319
+ | `props-mutation` | Assigning to props inside the component (`this.count = …`) |
320
+ | `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) |
321
+ | `unknown-prop` | An attribute written on a child component's tag is not a prop it declares (hints "Did you mean …") |
322
+ | `invalid-prop-value` | The attribute cannot set that prop: the prop is not string / number / boolean (it can only be passed through JS properties) |
323
+ | `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 |
324
+
325
+ 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. When several folders define the same tag (separate apps or packages), the definition in the same package as the template (the nearest folder with a `package.json`) is used; when that still leaves none or more than one, the tag is not checked.
326
+
327
+
328
+ Recommended practices:
329
+
330
+ - **Data and events cross only one layer**: the parent owns the data and passes props down, and the child notifies with events.
331
+ - **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.
332
+ - 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.
333
+
334
+ ```ts
335
+ // session.ts — components at any depth can use it directly, no layer-by-layer passing
336
+ export const session = createStore({ user: "Ada" });
337
+ // deep component
338
+ connected() { this.bind(session, (s) => (this.text.user = s.user)); }
339
+ ```
340
+
341
+ 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).
342
+
343
+ ### A Small Reminder About Form Inputs
344
+
345
+ 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.
346
+
347
+ ```ts
348
+ if (changed.has("value") && this.refs.input.value !== v.value) this.refs.input.value = v.value;
349
+ ```
350
+
351
+ The lint rule `unguarded-input-value` (in the `data-flow` group, also `ts-dom/unguarded-input-value` on its own) reports a `render()` that writes an `<input>`, `<textarea>` or `<select>` value without this comparison. Writing a value in an event handler, such as clearing a field after submit, is fine and not reported.
352
+
353
+ 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.
354
+
355
+ ## State: store (when not using state)
356
+
357
+ ```ts
358
+ const store = createStore({ count: 0 });
359
+ store.get();
360
+ store.set({ count: 1 });
361
+ store.set((s) => ({ count: s.count + 1 }));
362
+ const off = store.subscribe((s) => console.log(s)); // returns an unsubscribe function
363
+ ```
364
+
365
+ `store.set` replaces the whole value; it does not merge like a component's `this.set`, because a store can hold any value (a number, an array). For an object with several fields, spread the rest yourself: `store.set((s) => ({ ...s, status: "loading" }))`.
366
+
367
+ `this.bind` also accepts a read-only source that has only `get` and `subscribe`: its parameter is the exported type `ReadableStore<T>` (`Pick<Store<T>, "get" | "subscribe">`), which you can use to type a source you pass around. There is no dependency tracking: after `set`, all subscribers are called; if the value is the same under `Object.is`, no notification is sent. `bind(source, callback)` calls the callback at once with `source.get()`, then with every value `subscribe` delivers, and unsubscribes when the element is removed. A `bind` made in `connected()` is made again on re-insertion, and then delivers the current value at once; a `bind` made elsewhere (for example in the constructor) ends on removal and does not come back. The source's `subscribe` must return the function that unsubscribes, and must not call the listener when it subscribes: a store whose `subscribe` does (nanostores' atoms, for example) calls the callback twice at connect; pass `{ get: atom.get, subscribe: atom.listen }` instead. 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.
@@ -0,0 +1,80 @@
1
+ # Developing Nativele
2
+
3
+ Part of the [Nativele](../README.md) documentation.
4
+
5
+ ## Project Structure
6
+
7
+ ```
8
+ src/lib/
9
+ component.ts Component base class, define
10
+ list.ts keyed lists
11
+ cond.ts data-if
12
+ text.ts {{ }} holes
13
+ slots.ts slots for Light DOM
14
+ style.ts style injection for Light mode, scoped
15
+ store.ts createStore
16
+ template.ts Template / Schema types
17
+ codegen.ts parse5 parsing, diagnostics, generating .html.ts
18
+ vite-plugin.ts optional Vite plugin (@nativele/ts-dom/vite)
19
+ lint.ts unused markup and data flow checks
20
+ reserved.ts reserved names (collisions with DOM and ts-dom members)
21
+ a11y.ts ARIA / accessibility checks
22
+ oxlint-plugin.ts oxlint JS plugin (@nativele/ts-dom/oxlint)
23
+ check.ts ts-dom check
24
+ doctor.ts ts-dom doctor
25
+ skills.ts ts-dom skills: listing, stale checks, copying
26
+ src/cli.ts the ts-dom command
27
+ skills/ Skills shipped with the package; an example/ is a reference project from the Skill's agent trial
28
+ examples/ user-card, todo, signup, readme and recipes examples, demo entry
29
+ test/ unit tests (happy-dom)
30
+ browser/ real Chrome tests and fixtures
31
+ ```
32
+
33
+ ## Developing This Project
34
+
35
+ | Command | Description |
36
+ |---|---|
37
+ | `npm run dev` | Start the demo (`vp dev`) |
38
+ | `npm run build` | Build the demo into `demo-dist/` |
39
+ | `npm run build:lib` | Build the library into `dist/` |
40
+ | `npm run smoke` | After packing, verify `gen → tsc → lint → build` in a fresh project with Vite installed |
41
+ | `npm run smoke:no-bundler` | After packing, verify `gen → tsc → running in a browser` in a fresh project with **only typescript installed** |
42
+ | `npm run verify:pack` | Build and check the publishing configuration with `publint` and `attw` (runs automatically before `npm pack`) |
43
+ | `npm run gen` | Generate the `.html.ts` files for examples and tests |
44
+ | `npm run check` | Run `tsc` after `gen` |
45
+ | `npm test` | Unit tests (happy-dom) |
46
+ | `npm run test:browser` | Tests that run in real Chrome (see below) |
47
+ | `npm run lint` | `vp lint`, including `ts-dom/unused-markup` |
48
+ | `npm run lint:html` | The CLI version of the same checks |
49
+
50
+ Examples in the repo point directly to `src/lib` through an alias in `vite.config.ts`, so no packing is needed first.
51
+
52
+ To run a Skill's example project: `npm run build:lib`, then in `skills/<name>/example` run `npm install` and `npm run verify` (it depends on the library in this repository through `file:../../..`).
53
+
54
+ ### Browser tests
55
+
56
+ `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.
57
+
58
+ | Environment variable | Description |
59
+ |---|---|
60
+ | `CHROME_BIN` | Path to Chrome, default `/usr/bin/google-chrome` |
61
+ | `TSDOM_CHROME_HOME` | HOME used by Chrome, default `/tmp/chome` (avoids crashpad write failures under WSL or a read-only home directory) |
62
+
63
+ The tests always use `--no-sandbox --no-zygote`, which suits WSL and container environments; they can also run on an ordinary desktop.
64
+
65
+ 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.
66
+
67
+ ## CI
68
+
69
+ A pull request runs only the jobs its changed files need, and a push to `main` does the same for the pushed commits. The jobs that are not needed still report as skipped, so the required checks pass.
70
+
71
+ | Changed files | Jobs that run |
72
+ |---|---|
73
+ | `src/`, `package.json`, `package-lock.json`, `tsconfig.json`, `vite.config.ts`, `.github/workflows/ci.yml` | all of `ci.yml`, and the `SKILL.md` format check |
74
+ | `test/`, `scripts/bench.ts`, `index.html` | types, unit tests and lint |
75
+ | `browser/`, `examples/` | types, unit tests and lint, and the browser tests |
76
+ | `scripts/consumer-smoke.sh`, `scripts/no-bundler-smoke.sh` | types, unit tests and lint, and that smoke test |
77
+ | `skills/`, `docs/`, the READMEs, `llms.txt` | the `SKILL.md` format check, which also checks their links (`skills.yml`) |
78
+ | Other Markdown, `LICENSE`, `.gitignore`, other workflows | nothing |
79
+
80
+ Every job reports on every pull request, skipped when not needed, so all of them are required checks for `main`.
package/docs/design.md ADDED
@@ -0,0 +1,55 @@
1
+ # Design
2
+
3
+ Part of the [Nativele](../README.md) documentation.
4
+
5
+ ## Design Principles
6
+
7
+ - HTML is the single source of structure.
8
+ - TypeScript is the single source of behavior and state.
9
+ - The DOM is the single source of truth for the UI; there is no virtual tree.
10
+ - Updates are explicit, local DOM operations; there is no whole-tree diff.
11
+ - Component lifecycle reuses Custom Elements.
12
+ - State subscriptions exist only where you call `bind`.
13
+ - There is no template language: HTML contains only **names**, never expressions.
14
+ - Type safety: names are derived by codegen, and tsc and lint can check that the HTML and TS match.
15
+
16
+ Comparison with Vue templates:
17
+
18
+ | Vue | ts-dom | Difference |
19
+ |---|---|---|
20
+ | `{{ expr }}` | `{{ name }}` | Must be a single identifier, not a path or an operation |
21
+ | `v-if` | `data-if="name"` | The condition is decided by TS calling `show(name, bool)` |
22
+ | `v-for` | `<template data-each="name">` | The list is updated by TS calling methods such as `replace` |
23
+ | `<slot>` | `<slot name>` | In Light mode the library moves the content; with `shadow: true` it uses native slots |
24
+ | `<style scoped>` | `<style scoped>` | Implemented with native `@scope`; without `scoped` it is global |
25
+ | `<template>` | The HTML file itself | One `.html` file is one component template |
26
+
27
+ ## Design Decisions
28
+
29
+ These are deliberate trade-offs, not missing features:
30
+
31
+ - **`{{ }}` 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.
32
+ - **No attribute binding**: get the element with `data-ref`, then set the attribute in TS.
33
+ - **`<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.
34
+ - **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.
35
+ - **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`.
36
+ - **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.
37
+ - **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.
38
+ - **`data-if` is hidden by default**: state lives only in TS, and nothing is shown unless it says so.
39
+ - **`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.
40
+
41
+ ## Limitations
42
+
43
+ - When running directly in the browser (no bundler), you must provide an import map for `@nativele/ts-dom` yourself, and relative imports must use the `.js` extension (the NodeNext convention).
44
+ - Event names for `data-on-*` must be lowercase; non-bubbling events handle only the target itself.
45
+ - 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.
46
+ - The `CustomEvent` received by an event handler method must be cast with `EmitEvent<child component, "event name">`, because HTML attributes cannot carry types.
47
+ - `props` can be passed through HTML attributes only when the default value is a string / number / boolean.
48
+ - Automatic binding covers only text holes and `data-if`; lists and attributes must be written in `render`.
49
+ - Version 0.x, and the API may still change; see [CHANGELOG.md](../CHANGELOG.md) for the versioning policy.
50
+ - No SSR or hydration.
51
+ - No attribute binding (deliberately; use `refs`).
52
+ - The unused-markup lint compares against the class body text, and is looser for names inside list items (it only looks at identifiers).
53
+ - `<style scoped>` falls back to CSS nesting in browsers that do not support `@scope`, which requires browser support for nesting (mainstream since 2023).
54
+ - ARIA checks cover only common items in static HTML (see the list above) and are not a complete accessibility audit.
55
+ - Browser tests are based on Chromium; Firefox 157 passed manual verification, and WebKit (Safari) is not verified.