@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.zh-TW.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
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
|
+
**為 AI agent 設計,由 TypeScript 驅動,以原生 HTML 定義。**
|
|
4
6
|
|
|
5
7
|
Nativele 以 `@nativele/ts-dom` 發布這個函式庫(從 `@nativele/ts-dom` 匯入),命令列工具是 `ts-dom`。
|
|
6
8
|
|
|
7
9
|
[English](README.md) | 繁體中文
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
用來建構 Web Components 的輕量函式庫:HTML 描述結構與資料要放的位置,TypeScript 負責行為與狀態,由原生 DOM 渲染,沒有虛擬 DOM,也沒有範本表達式。它為型別安全與 AI agent 協作而設計:`tsc` 會檢查 HTML 用到的每個名稱,每則診斷都說明怎麼修。複雜的介面以原生自訂元素組合而成。
|
|
12
|
+
|
|
13
|
+
**Nativele 是為 AI coding agent 打造的。** agent 寫好範本與類別,執行 `ts-dom check`,就能從型別錯誤自行修正:HTML 用了類別沒有的名稱、事件處理方法不存在、清單項目的形狀不對。沒有要另外學的範本語言,也沒有要推敲的隱式響應式,agent 在 HTML 與類別裡讀到的就是實際發生的全部。`ts-dom gen` 與 `ts-dom lint` 的每則診斷都標明規則並附上可直接套用的 `Fix:` 提示,[`llms.txt`](llms.txt) 用一頁說明整個 API,[Nativele Skills](#nativele-skills) 則提供 agent 經過驗證的流程,用於開發元件以及與其他工具整合。
|
|
10
14
|
|
|
11
15
|
```html
|
|
12
16
|
<!-- user-card.html:HTML 只描述結構、資料要放的位置、事件要找哪個方法 -->
|
|
@@ -42,17 +46,28 @@ HTML 用了 `state` 沒有的名稱、`data-on-click` 指向不存在的方法
|
|
|
42
46
|
5. **props 往下、事件往上**:父元件設定 props;子元件呼叫 `this.emit("name", detail)`,父元件在子元件的標籤上用 `data-on-name` 處理。
|
|
43
47
|
6. **處理方法是公開方法** `(event, hit)`;在清單項目內 `hit.key` 就是該項目的 key。
|
|
44
48
|
|
|
45
|
-
##
|
|
49
|
+
## Nativele Skills
|
|
50
|
+
|
|
51
|
+
Nativele 隨套件提供 Skill:給 coding agent 的任務流程,每份都有設定步驟、完整範例、已知限制、驗證結果的指令,以及驗證時使用的確切版本。API 的事實寫在指南裡;Skill 告訴 agent 怎麼用它們完成一件工作。
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npx ts-dom skills # 列出已安裝版本提供的 Skill
|
|
55
|
+
npx ts-dom skills nativele-core # 顯示其中一份
|
|
56
|
+
npx ts-dom skills add nativele-core # 複製到 .claude/skills/(專案有 .claude 時)或 .agents/skills/
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`npx ts-dom doctor` 會讀取你的 `package.json` 並建議相符的 Skill,例如相依 React 時建議 `nativele-react`。
|
|
60
|
+
|
|
61
|
+
| Skill | 內容 |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `nativele-core` | 開發元件:`ts-dom check` 循環、範本、state、清單、資料流 |
|
|
64
|
+
| `nativele-react` | 在 React 19 中使用 Nativele 元件:props、事件、子元素與 slot、生命週期、TSX 型別 |
|
|
65
|
+
|
|
66
|
+
等級:Official 由維護者以 CI 中的範例專案驗證;Community 由社群貢獻,標示作者與最後驗證版本;Experimental 尚未充分驗證。驗證超過六個月,或不涵蓋已安裝的版本時,`ts-dom skills` 會標示為可能過期。
|
|
46
67
|
|
|
47
|
-
|
|
68
|
+
Nativele 維持輕量的 UI 層,整合能力透過 Skill 擴展:新增 Skill 不改動執行期程式碼,也不增加依賴。規劃中的 Skill 與貢獻方式見 [Skills 指南](https://github.com/Pixmi/nativele/blob/main/skills/README.md)。
|
|
48
69
|
|
|
49
|
-
|
|
50
|
-
- 改了任何 `.html` 之後,執行 `npx ts-dom gen && npx tsc`,然後把型別回報的每個錯誤(`state` 少了某個名稱、少了處理方法)修好,不要用型別斷言蓋掉。
|
|
51
|
-
- 範本一律以 `./x.html.js` 匯入。
|
|
52
|
-
- HTML 裡不放表達式:計算值放進 `derive`,state 只用 `this.set()` 改。
|
|
53
|
-
- `ts-dom gen` 與 `ts-dom lint` 的每則診斷結尾都有 `Fix:` 提示,照做。`npx ts-dom rules` 列出所有規則編號,`npx ts-dom lint --a11y` 另外檢查無障礙。
|
|
54
|
-
- `data-each` 清單不能巢狀:把內層清單放進自己的 `.html`,當成子元件。
|
|
55
|
-
- 元件組成的清單:在 `<template data-each>` 寫 `data-key` 與 `data-prop`,在 `<ul>` 或 `<ol>` 裡的項目元件加 `role="listitem"`,以 `this.lists.x.items = ...` 指派,變動的項目要給新物件(不要就地修改已指派的物件),並讓 `tsc` 檢查項目型別。
|
|
70
|
+
如果你的 agent 不會載入 Skill,在它的指引檔(`AGENTS.md`、`CLAUDE.md` 等)加一行:「處理 Nativele 元件前,先執行 `npx ts-dom skills nativele-core` 並照著做。」
|
|
56
71
|
|
|
57
72
|
## 快速開始
|
|
58
73
|
|
|
@@ -62,7 +77,7 @@ npx ts-dom gen # 為每個 x.html 產生 x.html.ts(含型別);每次
|
|
|
62
77
|
npx tsc # 型別檢查並編譯;不需要 Vite 或其他 bundler
|
|
63
78
|
```
|
|
64
79
|
|
|
65
|
-
|
|
80
|
+
下面兩個範例都是 `examples/readme/` 裡真實可執行的檔案(有型別檢查、lint 與測試)。
|
|
66
81
|
|
|
67
82
|
### 範例 1:計數器(state、衍生值、事件、data-if)
|
|
68
83
|
|
|
@@ -97,849 +112,113 @@ define("my-counter", MyCounter);
|
|
|
97
112
|
|
|
98
113
|
HTML 用了 `state` 與 `derive` 都沒有的名稱、`data-on-click` 指向不存在的方法,`tsc` 都會報錯。
|
|
99
114
|
|
|
100
|
-
### 範例 2
|
|
115
|
+
### 範例 2:元件組成的清單(`items =`、props 往下、事件往上)
|
|
116
|
+
|
|
117
|
+
項目有自己的東西(狀態、事件、內層清單)時,就把它做成元件,整份清單用一次指派填入。項目元件以 prop 接收自己的 task,用事件把變更往上送:
|
|
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
|
-
- `data-each` 的 `<template>` 是清單項目;自動綁定不處理清單,所以清單在 `render` 裡用 `replace` 填入。
|
|
168
|
-
- 事件用委派:清單項目內的 `data-on-change`、`data-on-click` 收到的 `hit.key` 就是該項目的 key。
|
|
169
|
-
- `{{ remaining }}` 是衍生值,自動更新。
|
|
170
|
-
|
|
171
|
-
### 範例 3:父子元件(props 往下、事件往上)
|
|
172
|
-
|
|
173
|
-
子元件只擁有 `props`(由父層給、唯讀),要改值時用 `emit` 請父層處理:
|
|
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
|
-
|
|
156
|
+
父元件在 `<template data-each>` 上寫出識別項目的欄位(`data-key`)與接收項目的 prop(`data-prop`),在 `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
|
-
資料流只有一個方向:父層的 state → 子元件的 props(`this.refs.qty.value = ...`);子元件的請求 → 事件 → 父層的處理方法。
|
|
231
|
-
|
|
232
|
-
## 設計原則
|
|
233
|
-
|
|
234
|
-
- HTML 是唯一的結構描述來源。
|
|
235
|
-
- TypeScript 是唯一的行為與狀態來源。
|
|
236
|
-
- DOM 是唯一的 UI 真實呈現,沒有虛擬樹。
|
|
237
|
-
- 更新是明確的局部 DOM 操作,沒有全樹 diff。
|
|
238
|
-
- 元件生命週期沿用 Custom Elements。
|
|
239
|
-
- 狀態訂閱只在你呼叫 `bind` 的地方存在。
|
|
240
|
-
- 沒有模板語言:HTML 裡只放**名稱**,不放表達式。
|
|
241
|
-
- 型別安全:名稱由 codegen 推導,tsc 與 lint 能檢查 HTML 與 TS 是否對得上。
|
|
242
|
-
|
|
243
|
-
和 Vue 模板的對照:
|
|
244
|
-
|
|
245
|
-
| Vue | ts-dom | 差異 |
|
|
246
|
-
|---|---|---|
|
|
247
|
-
| `{{ expr }}` | `{{ name }}` | 只能是單一識別字,不能是路徑或運算 |
|
|
248
|
-
| `v-if` | `data-if="name"` | 條件由 TS 呼叫 `show(name, bool)` 決定 |
|
|
249
|
-
| `v-for` | `<template data-each="name">` | 清單由 TS 呼叫 `replace` 等方法更新 |
|
|
250
|
-
| `<slot>` | `<slot name>` | 預設 Light 由函式庫搬移;`shadow: true` 用原生 slot |
|
|
251
|
-
| `<style scoped>` | `<style scoped>` | 以原生 `@scope` 實作;沒寫 `scoped` 就是全域 |
|
|
252
|
-
| `<template>` | HTML 檔本身 | 一個 `.html` 檔就是一個元件範本 |
|
|
253
|
-
|
|
254
|
-
## 安裝與設定
|
|
255
|
-
|
|
256
|
-
只需要 TypeScript,**不需要 Vite 或任何 bundler**:
|
|
257
|
-
|
|
258
|
-
```sh
|
|
259
|
-
npm i -D typescript
|
|
260
|
-
npm i @nativele/ts-dom
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
`tsconfig.json`(與一般 TS 專案相同,沒有特殊選項):
|
|
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"
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
流程:先產生範本模組,再用 `tsc` 編譯與型別檢查。
|
|
280
|
-
|
|
281
|
-
```sh
|
|
282
|
-
npx ts-dom gen # 為 src/ 下每個 x.html 產生 x.html.ts
|
|
283
|
-
npx tsc # 型別檢查並編譯到 out/
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
文字中的空白預設照原樣保留。若想把連續空白收成一個空格、並丟掉標籤之間只含換行的文字(讓縮排的 HTML 不產生多餘空格),用 `npx ts-dom gen --whitespace=collapse` 或 Vite 外掛選項 `whitespace: "collapse"`;`<pre>` 與 `<textarea>` 不受影響。
|
|
287
|
-
|
|
288
|
-
只想產生部分範本時(例如分階段遷移專案),傳入相對於各目錄的 glob:`npx ts-dom gen src --include='new/**' --exclude='**/draft-*.html'`,或用 Vite 外掛選項 `include`、`exclude`。`*` 只比對同一層資料夾,`**` 可跨資料夾。
|
|
289
|
-
|
|
290
|
-
開發時可用 `npx ts-dom gen --watch` 持續監看 `.html`,搭配 `tsc --watch`。
|
|
291
|
-
|
|
292
|
-
在程式中以 `.js` 結尾匯入範本(標準 NodeNext 寫法,TS 會對應到產生的 `x.html.ts`):
|
|
293
|
-
|
|
294
|
-
```ts
|
|
295
|
-
import tpl from "./user-card.html.js";
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
### 在瀏覽器執行(不用 bundler)
|
|
299
|
-
|
|
300
|
-
編譯後的 JS 是標準 ES module,只有一個裸模組名 `ts-dom` 需要對應,用 import map 即可:
|
|
301
|
-
|
|
302
|
-
```html
|
|
303
|
-
<script type="importmap">
|
|
304
|
-
{ "imports": { "@nativele/ts-dom": "/node_modules/@nativele/ts-dom/dist/index.mjs" } }
|
|
305
|
-
</script>
|
|
306
|
-
<user-card></user-card>
|
|
307
|
-
<script type="module" src="/out/main.js"></script>
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
這個流程(只裝 `typescript`、不裝 Vite,從 `gen` 到真實瀏覽器執行)有自動化驗證:`npm run smoke:no-bundler`。
|
|
311
|
-
|
|
312
|
-
### 搭配 Vite(選用)
|
|
313
|
-
|
|
314
|
-
用 bundler 時不需要 import map,`@nativele/ts-dom/vite` 外掛只是方便:建置時自動產生 `.html.ts`、dev 時監看 `.html` 重新產生、可選的 ARIA 警告。
|
|
315
|
-
|
|
316
|
-
```ts
|
|
317
|
-
import { defineConfig } from "vite-plus"; // 或 "vite"
|
|
318
|
-
import { tsDom } from "@nativele/ts-dom/vite";
|
|
319
|
-
|
|
320
|
-
export default defineConfig({
|
|
321
|
-
plugins: [tsDom()], // 選項:dirs(預設 ["src"])、runtime、whitespace、include、exclude、a11y
|
|
322
|
-
});
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
dev server 執行期間,外掛會讓產生檔保持同步:新增或編輯範本會重新產生它的 `x.html.ts`,若有頁面用到就重新載入一次(存檔但內容沒變不會重載);刪除範本會移除它的 `x.html.ts`;新增或移除 `define()` 會更新 `ts-dom-tags.ts` 與範本的子元件匯入。`dirs` 相對於 Vite 的 `root`。這裡是重新載入而不是熱替換元件,因為同一個自訂元素標籤只能定義一次,新的類別無法取代正在執行的那個。
|
|
326
|
-
|
|
327
|
-
安裝時建議先裝 `vite-plus`,再裝 `@nativele/ts-dom`(npm 同一次安裝時可能不會把 `vite` 提升到頂層)。
|
|
328
|
-
|
|
329
|
-
### Lint(選用)
|
|
330
|
-
|
|
331
|
-
```ts
|
|
332
|
-
// vite.config.ts 的 lint 設定(vp lint / oxlint)
|
|
333
|
-
lint: {
|
|
334
|
-
ignorePatterns: ["node_modules", "dist", "**/*.html.ts"],
|
|
335
|
-
jsPlugins: [{ name: "ts-dom", specifier: "@nativele/ts-dom/oxlint" }],
|
|
336
|
-
rules: {
|
|
337
|
-
"ts-dom/unused-markup": "error",
|
|
338
|
-
"ts-dom/a11y": ["warn", { ignore: [] }], // 選用:ARIA 檢查,見「ARIA / 無障礙檢查」
|
|
339
|
-
"ts-dom/data-flow": ["warn", { ignore: [] }], // 選用:避免 prop 與事件逐層傳遞,見「資料流」
|
|
340
|
-
},
|
|
341
|
-
},
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
不用 oxlint 時,同樣的檢查可用 CLI:
|
|
345
|
-
|
|
346
|
-
```sh
|
|
347
|
-
npx ts-dom lint [dirs...] # 未使用標記(錯誤)與資料流建議(警告)
|
|
348
|
-
npx ts-dom lint --a11y # 另外檢查 ARIA / 無障礙(警告)
|
|
349
|
-
npx ts-dom lint --strict # 警告也使結束碼為 1(錯誤一定會)
|
|
350
|
-
npx ts-dom lint --json # 輸出 JSON:{ "issues": [{ file, line, col, severity, rule, message }] }
|
|
351
|
-
npx ts-dom rules # 列出所有規則代號與說明
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
每則訊息的格式都一樣,說明**哪裡錯、為什麼、怎麼修**,並附上 HTML 裡的行與欄:
|
|
355
|
-
|
|
356
|
-
```
|
|
357
|
-
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.
|
|
358
|
-
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.
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
結束碼:0 = 沒有問題,1 = 有錯誤(或使用 `--strict` 時有警告)。oxlint 外掛的訊息以 `./x.html.js:行:欄 [規則代號] 訊息` 開頭,回報在 `import` 那一行。
|
|
362
|
-
|
|
363
|
-
建議的檢查流程:`ts-dom gen && tsc && ts-dom lint`。
|
|
364
|
-
|
|
365
|
-
## 型別如何產生
|
|
366
|
-
|
|
367
|
-
每個 `x.html` 旁會產生 `x.html.ts`(已加入 `.gitignore`;另外會產生 `ts-dom-tags.ts`,見「組合」),它是一個真正的 TS 模組:同時包含這個範本解析後的標記(以資料表示,執行期用 DOM 呼叫轉成節點,瀏覽器不需要解析 HTML 字串)與結構描述,所以 `tsc` 直接就能編譯與檢查。
|
|
368
|
-
|
|
369
|
-
```ts
|
|
370
|
-
// user-card.html.ts(自動產生)
|
|
371
|
-
import { template } from "@nativele/ts-dom";
|
|
372
|
-
import type { El } from "@nativele/ts-dom";
|
|
373
|
-
export default template<{
|
|
374
|
-
refs: { "toggle": El<"button"> };
|
|
375
|
-
lists: { "tags": { refs: {}; text: "label"; ifs: "hot" } };
|
|
376
|
-
ifs: "admin";
|
|
377
|
-
slots: "extra";
|
|
378
|
-
text: "name";
|
|
379
|
-
}>([["h2", {}, ["Hello, {{ name }}!"]], /* ... */]);
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
整頁文件是頁面入口,不是範本,不會被處理。判斷依據是檔案內容:以 `<!doctype html>` 或 `<html>` 開頭(前面可以有註解)就是頁面,與檔名無關,所以 `index.html`、`main.html`、`login.html` 都可以。範本是元件的片段,因此其中的 `<html>`、`<head>`、`<body>` 會被拒絕(規則 `document-tag`):瀏覽器會把它們從片段中丟掉,放在上面的任何東西都不會有效果。想用別的方式讓檔案不被處理,可以用 `--exclude`。
|
|
383
|
-
|
|
384
|
-
## HTML 語法
|
|
385
|
-
|
|
386
|
-
只有幾種標記,加上一種洞。
|
|
387
|
-
|
|
388
|
-
| 標記 | 意義 | TS 端 |
|
|
389
|
-
|---|---|---|
|
|
390
|
-
| `data-ref="name"` | 取得該元素,型別依標籤推導 | `this.refs.name` |
|
|
391
|
-
| `data-if="name"` | 條件區塊,**預設隱藏**(從 DOM 移除),TS 說顯示才顯示 | `this.show("name", bool)` |
|
|
392
|
-
| `<template data-each="name">` | 清單,內容必須恰好一個根元素 | `this.lists.name` |
|
|
393
|
-
| `<slot name="x">` | 插槽 | 使用者提供 `slot="x"` 的子節點 |
|
|
394
|
-
| `{{ name }}` | 文字洞,同名可出現多處 | `this.text.name = value`,或由 `state` 自動綁定 |
|
|
395
|
-
| `data-on-click="method"` | 事件:呼叫元件的同名方法(事件名稱要小寫) | `method(e, hit)`,見「事件」 |
|
|
396
|
-
|
|
397
|
-
洞的規則:
|
|
398
|
-
|
|
399
|
-
- 只能是單一識別字。`{{ data.name }}`、`{{ a + 1 }}` 會在 codegen 時報錯。
|
|
400
|
-
- 只用於文字節點,屬性內不支援(`title="{{ x }}"` 會報錯)。需要時用 `data-ref` 取元素再在 TS 設定。
|
|
401
|
-
- `<style>`、`<script>` 內的內容不會被當成洞。
|
|
402
|
-
- 值為 `null`、`undefined`、`false` 時顯示為空字串。
|
|
403
|
-
|
|
404
|
-
## 元件
|
|
405
|
-
|
|
406
|
-
```ts
|
|
407
|
-
class MyEl extends Component(tpl) {} // 預設:Light DOM
|
|
408
|
-
class Iso extends Component(tpl, { shadow: true }) {} // 選用:Shadow DOM
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
提供的成員:
|
|
412
|
-
|
|
413
|
-
| 成員 | 說明 |
|
|
414
|
-
|---|---|
|
|
415
|
-
| `refs` | `data-ref` 對應的元素 |
|
|
416
|
-
| `text` | 洞,賦值即更新所有出現位置 |
|
|
417
|
-
| `lists` | 清單物件 |
|
|
418
|
-
| `show(name, on)` | 切換 `data-if`(預設隱藏) |
|
|
419
|
-
| `props` / `emit(name, detail)` | 使用 `props`、`emits` 選項時:父層給的資料(唯讀)與往上發出的事件,見「資料流」 |
|
|
420
|
-
| `slotchange(name)` | 覆寫以回應 Light 模式 slot 內容的後續變動 |
|
|
421
|
-
| `assignedNodes(name)` | 目前分配到該 slot 的節點(不含後備內容),Light 與 Shadow 行為一致 |
|
|
422
|
-
| `bind(store, fn)` | 訂閱 store,立即呼叫一次,元素移除時自動取消 |
|
|
423
|
-
| `on(target, type, fn)` | 綁定原生事件,元素移除時自動解除;回傳手動解除用的函式。事件型別與參數依 `HTMLElementEventMap` 推導 |
|
|
424
|
-
| `onCleanup(fn)` | 註冊元素移除時執行的清理函式 |
|
|
425
|
-
| `root` | 元素本身(預設),或 `ShadowRoot`(`shadow: true`) |
|
|
426
|
-
| `connected()` / `disconnected()` | 覆寫以掛上你的行為 |
|
|
427
|
-
|
|
428
|
-
生命週期就是 Custom Elements 的 `connectedCallback` / `disconnectedCallback`,你也可以直接覆寫它們(記得呼叫 `super`)。
|
|
429
|
-
|
|
430
|
-
### Light(預設)與 Shadow
|
|
431
|
-
|
|
432
|
-
ts-dom 不做樣式管理,所以預設渲染到元素本身(Light DOM):DOM 裡看到的就是你寫的結構,CSS 是全域的,表單與 ARIA 的 id 參照都照常運作。`shadow: true` 則使用 Shadow DOM。
|
|
433
|
-
|
|
434
|
-
| | Light(預設) | `shadow: true` |
|
|
435
|
-
|---|---|---|
|
|
436
|
-
| 渲染位置 | 元素本身 | 元素的 open shadow root |
|
|
437
|
-
| 樣式 | 全域;`<style scoped>` 可局部 | 原生隔離:文件的樣式進不來,元件的 `<style>` 出不去;`:host` 有效 |
|
|
438
|
-
| `<style>` | 抽出並只注入 `document.head` 一次 | 留在 shadow root 內,不注入 `<head>`;`scoped` 屬性沒有作用 |
|
|
439
|
-
| `<slot>` | 建立時一次性搬移,之後由 `MutationObserver` 維持 | 瀏覽器原生 |
|
|
440
|
-
| slot 變動通知 | 覆寫 `slotchange(name)` | **原生 `slotchange` 事件**(`slot.addEventListener("slotchange", …)`);`slotchange(name)` 方法只在 Light 模式呼叫 |
|
|
441
|
-
| `assignedNodes(name)` | 有 | 有(等同原生 `slot.assignedNodes()`) |
|
|
442
|
-
| `state`、`props`、`derive`、`emit`、`data-on-*`、清單、`data-if`、屬性 | 都支援 | 都支援,行為相同 |
|
|
443
|
-
| 表單 | 與外層表單自然相容 | 需自行處理 `ElementInternals` |
|
|
444
|
-
|
|
445
|
-
Shadow DOM 已在真實 Chrome 驗證([browser/shadow.test.ts](browser/shadow.test.ts),18 個測試),涵蓋:渲染與樣式隔離、`:host`、原生 slot 與 `slotchange`、屬性與 props(含升級前賦值)、`state` / `derive` / `data-if`、事件委派(含不冒泡的 `focus`)、`emit` 不冒泡到 `window`、清單項目內的子元件(`hit.key`)、**Shadow 父元件含 Light 子元件、Light 父元件含 Shadow 子元件**,以及使用者放進 slot 的內容不會被當成元件自己的 `data-on-*`。
|
|
446
|
-
|
|
447
|
-
Light 模式的 slot 規則:
|
|
448
|
-
|
|
449
|
-
- 依 `slot="name"` 比對,沒有 slot 屬性的進預設 slot,沒有內容時使用 `<slot>` 內的後備內容。
|
|
450
|
-
- 使用者內容裡的 `{{ }}`、`data-ref`、`data-if` 不會被解析,它們屬於使用者自己的 DOM。
|
|
451
|
-
- 與原生 Shadow DOM 相同,只有**宿主元素的直接子節點**會被分配(巢在更深層的節點不會)。建立後直接加到元素上的子節點會被分配到對應 slot;已分配的節點被改了 `slot` 屬性時會立即重新分配;已分配的節點被移除時,若該 slot 空了就恢復後備內容;沒有對應 slot 的節點與原生行為一致,不呈現。這些變動會呼叫 `slotchange(name)`(建立時的初次分配不會)。
|
|
452
|
-
- 動態變動靠 `MutationObserver`,在元素連接期間才觀察,移除時斷開。
|
|
453
|
-
|
|
454
|
-
```ts
|
|
455
|
-
class Card extends Component(tpl) {
|
|
456
|
-
override slotchange(name: "head" | "") { console.log("slot changed", name); }
|
|
457
|
-
}
|
|
458
|
-
```
|
|
459
|
-
|
|
460
|
-
### 樣式
|
|
461
|
-
|
|
462
|
-
範本頂層的 `<style>` 在 Light 模式有兩種寫法:
|
|
463
|
-
|
|
464
|
-
```html
|
|
465
|
-
<style>
|
|
466
|
-
user-card .tag { color: gray } /* 全域:整份文件共用 */
|
|
467
|
-
</style>
|
|
468
|
-
<style scoped>
|
|
469
|
-
:scope { display: block } /* 局部:只作用在 <user-card> 之內 */
|
|
470
|
-
.tag { color: gray }
|
|
471
|
-
</style>
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
- `<style>` **必須在 HTML 的第一層**,不可被其他元素(含 `<template data-each>`、`data-if` 元素)包覆,否則 codegen 報錯。
|
|
475
|
-
- 兩者都只會注入一次到 `document.head`,不會隨實例重複。
|
|
476
|
-
- `scoped` 在支援的瀏覽器用原生 CSS `@scope (<標籤名>)`;不支援時自動退回 CSS nesting(`<標籤名> { ... }`,`:scope` 轉成 `&`),兩者都不需要 CSS 解析器。`@scope` 的優先權以「離根最近」為準,不是只看特異性。
|
|
477
|
-
- Light 模式下 `:host` 不會生效,執行時會在 console 警告;請改用標籤選擇器或 `:scope`。
|
|
478
|
-
- `shadow: true` 時 `<style>` 保留在 shadow root 內,`scoped` 屬性沒有作用。
|
|
479
|
-
|
|
480
|
-
## 資料與畫面:state(選用)
|
|
481
|
-
|
|
482
|
-
最簡單的用法:給 `Component` 一個 `state`,改資料只用 `this.set()`。
|
|
483
|
-
|
|
484
|
-
```ts
|
|
485
|
-
class Counter extends Component(tpl, {
|
|
486
|
-
state: { title: "Todo", items: [] as Item[], filter: "all" },
|
|
487
|
-
derive: { remaining: (s) => s.items.filter((i) => !i.done).length }, // 衍生值
|
|
488
|
-
}) {
|
|
489
|
-
add(e: Event) {
|
|
490
|
-
this.set((s) => ({ items: [...s.items, { id: nextId++, title: "x", done: false }] }));
|
|
191
|
+
removeTask(_e: Event, hit: EventHit<Task, "id">): void {
|
|
192
|
+
this.set((s) => ({ tasks: s.tasks.filter((t) => t.id !== hit.key) }));
|
|
491
193
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (changed.has("items")) this.lists.items.replace(view.items, (i) => i.id, (i, { text }) => (text.title = i.title));
|
|
194
|
+
sortByTitle(): void {
|
|
195
|
+
this.set((s) => ({ tasks: [...s.tasks].sort((a, b) => a.title.localeCompare(b.title)) }));
|
|
495
196
|
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
| 成員 | 說明 |
|
|
500
|
-
|---|---|
|
|
501
|
-
| `state` | 目前的資料,唯讀。要改資料請用 `set` |
|
|
502
|
-
| `set(patch)` / `set(s => patch)` | **唯一改資料的入口**:淺合併 patch;**沒有任何欄位實際變動(`Object.is`)時什麼都不做**,不會呼叫 `render`。有變動時同步更新自動綁定,再呼叫 `render` |
|
|
503
|
-
| `render(view, changed)` | 覆寫它處理自動綁定以外的更新。`view` 是 `state` 加上衍生值,`changed` 是這次有變動的 state 欄位(首次渲染時為全部)。用 `changed.has("x")` 就能略過沒變的部分 |
|
|
504
|
-
| `batch(fn)` | 合併多次 `set`:`fn` 結束後只更新一次、呼叫一次 `render` |
|
|
505
|
-
| `derive` 選項 | 衍生值,可直接被 `{{ }}`、`data-if` 與 `render` 使用。每次 state 有變動後重新計算,**沒有依賴追蹤** |
|
|
506
|
-
|
|
507
|
-
**自動綁定**:`{{ name }}` 與 `data-if="admin"` 會對應到同名的 state 欄位(或衍生值),欄位值變動時自動寫入;`data-if` 依真假值顯示。**名稱由 tsc 檢查**:HTML 用了 `state`、`derive` 都沒有的名稱時,`class X extends Component(...)` 會報錯並列出缺少的名稱(錯誤訊息是 `missingStateKeys: "名稱"`)。清單(`data-each`)不自動綁定,因為 key 與每項怎麼填要由你決定,請在 `render` 裡用 `replace`。
|
|
508
|
-
|
|
509
|
-
設計上的取捨:
|
|
510
|
-
|
|
511
|
-
- **同步**:`set` 結束時畫面已更新,不做隱式的非同步合併,所以 `set` 之後立刻讀 DOM 一定是新的。需要合併請明確用 `batch`。
|
|
512
|
-
- **沒有細粒度更新**:每次有變動就整體重跑一次 `render`,成本取決於你在裡面寫了什麼。大清單請用 `changed` 略過不必要的重繪,並對 `replace` 開 `skipUnchanged`(見「清單」)。
|
|
513
|
-
- `render` 內可以呼叫 `set`,會在當次結束後再處理一輪;連續 50 輪還沒穩定會丟出「疑似無限迴圈」錯誤。
|
|
514
|
-
- 不使用 `state` 時,原本的 `refs`、`text`、`show`、`bind`、`createStore` 照常可用,完全向下相容。
|
|
515
|
-
|
|
516
|
-
## 事件:data-on-*
|
|
517
|
-
|
|
518
|
-
HTML 只寫事件名稱與方法名稱,邏輯在 TS:
|
|
519
|
-
|
|
520
|
-
```html
|
|
521
|
-
<form data-on-submit="add">…</form>
|
|
522
|
-
<template data-each="items"><li><button data-on-click="removeItem">✕</button></li></template>
|
|
523
|
-
```
|
|
524
|
-
|
|
525
|
-
```ts
|
|
526
|
-
add(e: Event) { e.preventDefault(); … }
|
|
527
|
-
removeItem(e: Event, hit: EventHit) { this.set((s) => ({ items: s.items.filter((i) => i.id !== hit.key) })); }
|
|
528
|
-
```
|
|
529
|
-
|
|
530
|
-
- 方法收到 `(event, hit)`。`hit.el` 是帶有 `data-on-*` 的元素;事件發生在清單項目內時,`hit.key` 是該項目的 key,`hit.item` 是最近一次傳給 `update` 的資料;放在項目內的子元件發出的事件也一樣。`hit.item` 預設是 `unknown`,要有型別就在處理方法上標註,例如 `hit: EventHit<Todo>`。需要額外參數就放 `data-*` 屬性,在方法中讀 `hit.el.dataset`。
|
|
531
|
-
- **事件委派**:每種事件只在元件根節點掛一個監聽器,清單再長也不會增加監聽器數量,項目增減不需要重新綁定。不冒泡的事件(`focus`、`blur`、`mouseenter` 等)改用捕獲階段,只處理 target 本身;冒泡事件會依序呼叫由內而外的處理方法,`stopPropagation()` 會停止。
|
|
532
|
-
- **只認元件自己範本裡的元素**:使用者放進 slot 的內容就算寫了 `data-on-click` 也不會觸發。
|
|
533
|
-
- 只能寫方法名稱,`add(1)`、`a.b` 會在 codegen 時報錯;方法名稱不可與 DOM 內建方法或 ts-dom 成員衝突(例如 `remove`、`click`、`focus`、`set`、`render`),否則會覆蓋它們,codegen 會報錯並建議改名。
|
|
534
|
-
- **tsc 檢查方法存在**:`define(name, Class)` 會檢查 HTML 引用的方法都是類別的 public 成員,缺少時報錯並列出名稱(`missingHandlers: "名稱"`)。連接到頁面時也會再檢查一次,丟出明確的執行期錯誤。
|
|
535
|
-
- 事件名稱要小寫(HTML 屬性不分大小寫);`DOMContentLoaded` 這類大小寫混合的事件請改用 `this.on()`。
|
|
536
|
-
|
|
537
|
-
## 組合:多個元件一起用
|
|
538
|
-
|
|
539
|
-
每個 `.html` 加上它的 TS 就是一個元件(自訂元素),在別的範本裡直接寫標籤就是組合:
|
|
540
|
-
|
|
541
|
-
```html
|
|
542
|
-
<!-- signup-form.html -->
|
|
543
|
-
<form-field data-ref="name" label="姓名" data-on-field-input="onName"></form-field>
|
|
544
|
-
<template data-each="entries">
|
|
545
|
-
<li><entry-row data-ref="row" data-on-remove-entry="removeEntry"></entry-row></li>
|
|
546
|
-
</template>
|
|
547
|
-
```
|
|
548
|
-
|
|
549
|
-
```ts
|
|
550
|
-
render(v) {
|
|
551
|
-
this.refs.name.value = v.name; // 資料往下:子元件的 props,有完整型別
|
|
552
|
-
this.lists.entries.replace(v.entries, (x) => x.id, (x, { refs }) => { refs.row.name = x.name; });
|
|
553
|
-
}
|
|
554
|
-
onName(e: Event) { // 事件往上:只送到這個標籤的直接父層
|
|
555
|
-
this.set({ name: (e as EmitEvent<FormField, "field-input">).detail.value });
|
|
556
|
-
}
|
|
557
|
-
```
|
|
558
|
-
|
|
559
|
-
組合需要三件事,ts-dom 都處理好了:
|
|
560
|
-
|
|
561
|
-
- **自動載入子元件**:範本用到的標籤(名稱含連字號),若在掃描目錄內有 `define("tag", Class)`,`ts-dom gen` 會在產生的 `x.html.ts` 裡自動加上 `import "./child.js"`。HTML 是唯一的依賴來源,不需要自己 `import` 子元件(只用 `import type` 引用型別並不會註冊元素,這是很容易踩到的坑)。目錄之外或第三方的元件,仍需自己 import。
|
|
562
|
-
- **子元件的型別**:`ts-dom gen` 掃描 `define("tag", Class)`(類別要 `export`),產生 `ts-dom-tags.ts`,把元件登錄到 `HTMLElementTagNameMap`。於是範本裡 `<form-field data-ref="name">` 的 `refs.name` 就是 `FormField`:props 的名稱與型別、方法都由 tsc 檢查。這個檔案已加入 `.gitignore`,與 `.html.ts` 一樣由 `gen` 產生。
|
|
563
|
-
- **slot 可以跨層**:父層放進子元件 slot 的內容,其 `{{ }}` 屬於父層,由父層更新;`data-on-*` 也由父層處理,子元件不會誤接。
|
|
564
|
-
|
|
565
|
-
沒有提供「範本片段 include」:那會變成模板語言的功能。要共用一段結構,請把它做成元件。
|
|
566
|
-
|
|
567
|
-
## 資料流:props 往下、事件往上
|
|
568
|
-
|
|
569
|
-
`props`、`emits` 是 `state` 之外的兩個選項,讓父子元件之間的資料流有明確的規則:
|
|
570
|
-
|
|
571
|
-
```ts
|
|
572
|
-
class FormField extends Component(tpl, {
|
|
573
|
-
props: { label: "", value: "", error: "" }, // 父層給的(唯讀)
|
|
574
|
-
state: { touched: false }, // 自己擁有的
|
|
575
|
-
derive: { showError: (s) => s.touched && s.error !== "" },
|
|
576
|
-
emits: { "field-input": event<{ value: string }>() }, // 會往上發出的事件
|
|
577
|
-
}) {
|
|
578
|
-
onInput() {
|
|
579
|
-
this.set({ touched: true });
|
|
580
|
-
this.emit("field-input", { value: this.refs.input.value });
|
|
197
|
+
override render(view: { tasks: readonly Task[] }, changed: ReadonlySet<"tasks">): void {
|
|
198
|
+
if (changed.has("tasks")) this.lists.tasks.items = view.tasks;
|
|
581
199
|
}
|
|
582
200
|
}
|
|
201
|
+
define("task-board", TaskBoard);
|
|
583
202
|
```
|
|
584
203
|
|
|
585
|
-
|
|
586
|
-
|---|---|---|---|
|
|
587
|
-
| `state` | 元件自己 | 元件自己 | `this.set()` |
|
|
588
|
-
| `props` | 父層 | 父層 | HTML 屬性(`user-name="Ada"`)或 JS 屬性(`el.userName = "Ada"`) |
|
|
589
|
-
| 事件 | 元件自己發出 | 任何人訂閱 | `this.emit(name, detail)` |
|
|
590
|
-
|
|
591
|
-
- `props` 的預設值決定型別與屬性的轉換:`string`、`number`(無效的數字會警告並用預設值)、`boolean`(屬性存在即 `true`,`"false"` 與移除為 `false`)。陣列與物件只能用 JS 屬性傳入。camelCase 的名稱對應 kebab-case 屬性:`maxLength` ↔ `max-length`。
|
|
592
|
-
- `props` 與 `state`、`derive` 一樣可被 `{{ }}`、`data-if` 自動綁定,也出現在 `render` 的 `view` 與 `changed` 裡;元素在升級前就被賦值的 prop 也不會遺失。
|
|
593
|
-
- `emits` 的事件預設**不冒泡**,只有子元件那個標籤本身收得到:處理位置一定寫在父層 HTML 的 `<child data-on-xxx="method">` 上,從 HTML 就能看出資料往哪走。需要冒泡才明確宣告 `event({ bubbles: true })`。
|
|
594
|
-
- 事件的資料型別由 `EmitEvent<FormField, "field-input">` 取得(`CustomEvent<{ value: string }>`)。
|
|
595
|
-
|
|
596
|
-
### 型別與執行期的防線
|
|
597
|
-
|
|
598
|
-
| 問題 | 防範 |
|
|
599
|
-
|---|---|
|
|
600
|
-
| 在元件內用 `set()` 修改 prop | `set` 的型別不含 props;執行期丟出明確錯誤 |
|
|
601
|
-
| `emit` 未宣告的事件、資料型別錯誤、該帶資料卻沒帶 | tsc 報錯;執行期未宣告的事件丟出錯誤 |
|
|
602
|
-
| `state` / `props` / `derive` 同名 | tsc 報錯(`conflictingKeys`);`Component()` 呼叫時丟出錯誤 |
|
|
603
|
-
| prop 名稱與 `HTMLElement` 或 ts-dom 成員同名(`title`、`hidden`、`render`…) | tsc 報錯(`reservedPropNames`);執行期丟出錯誤 |
|
|
604
|
-
| `emits` 與原生事件同名(`click`、`input`…) | tsc 報錯(`nativeEventNames`);執行期丟出錯誤 |
|
|
605
|
-
| 父層給子元件的 prop 名稱或型別錯誤 | tsc 報錯(由 `HTMLElementTagNameMap` 提供型別) |
|
|
606
|
-
|
|
607
|
-
### 避免層層傳遞(lint:`ts-dom/data-flow`)
|
|
608
|
-
|
|
609
|
-
Vue 常見的問題是逐層重新 emit、逐層往下傳 prop,靠習慣維持。這裡除了「事件預設不冒泡」之外,還有 lint 提示(只是建議,不是錯誤):
|
|
610
|
-
|
|
611
|
-
| 代號 | 偵測 |
|
|
612
|
-
|---|---|
|
|
613
|
-
| `prop-forwarding` | 把自己的 prop 原樣賦給子元件(`this.refs.kid.name = v.name`,其中 `name` 是自己的 prop) |
|
|
614
|
-
| `emit-forwarding` | 處理子元件事件的方法只是把事件再 `emit` 一次(同名,或整個方法只有一個 `this.emit(...)`) |
|
|
615
|
-
| `props-mutation` | 在元件內對 props 賦值(`this.count = …`) |
|
|
616
|
-
| `custom-event-on-native` | 把自訂事件寫在原生元素的 `data-on-*` 上(子元件的事件不冒泡,這樣收不到) |
|
|
617
|
-
| `unknown-prop` | 寫在子元件標籤上的屬性不是它宣告的 prop(會提示「你是不是想打…」) |
|
|
618
|
-
| `invalid-prop-value` | 屬性無法設定該 prop:prop 不是 string / number / boolean(只能用 JS 屬性傳入) |
|
|
619
|
-
| `unknown-event` | `data-on-xxx` 寫在子元件標籤上,但子元件沒有宣告(`emits`)這個事件,也不是原生事件 |
|
|
620
|
-
|
|
621
|
-
最後三項是 **HTML 層級的 props / emit 檢查**:`tsc` 檢查 TypeScript 裡的 `refs.child.prop` 與 `emit()`,這三條則檢查寫在**父層 HTML** 裡的子元件屬性與事件。它們會讀取目錄內 `define("tag", Class)` 的子元件,從 `Component(tpl, { props, emits })` 取出宣告;找不到子元件定義時(例如第三方元件)不會檢查。
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
建議的做法:
|
|
625
|
-
|
|
626
|
-
- **資料與事件只跨一層**:父層擁有資料,往下傳 props,子元件用事件通知。
|
|
627
|
-
- **跨多層共享的資料用共享 store**:把 `createStore` 放在獨立模組,需要的元件直接 import 並用 `this.bind(store, fn)` 訂閱,中間層不必知道、也不必轉傳。
|
|
628
|
-
- 事件要跨兩層時,請在中間層明確處理它(它有自己的邏輯),而不是原樣轉發。
|
|
629
|
-
|
|
630
|
-
```ts
|
|
631
|
-
// session.ts —— 任何深度的元件都能直接用,不用逐層傳
|
|
632
|
-
export const session = createStore({ user: "Ada" });
|
|
633
|
-
// 深層元件
|
|
634
|
-
connected() { this.bind(session, (s) => (this.text.user = s.user)); }
|
|
635
|
-
```
|
|
636
|
-
|
|
637
|
-
開啟方式與其他 lint 相同:`"ts-dom/data-flow": ["warn", { ignore: [] }]`,或 CLI `npx ts-dom lint`(會一併顯示資料流警告,加 `--strict` 則使結束碼為 1)。
|
|
638
|
-
|
|
639
|
-
### 表單輸入的小提醒
|
|
640
|
-
|
|
641
|
-
在 `render` 裡把資料寫回 `<input>` 時,請先比對值是否相同:資料經「輸入 → emit → 父層 → props」回到子元件時,若重複寫入同樣的值,游標會跳到最後面。
|
|
642
|
-
|
|
643
|
-
```ts
|
|
644
|
-
if (changed.has("value") && this.refs.input.value !== v.value) this.refs.input.value = v.value;
|
|
645
|
-
```
|
|
646
|
-
|
|
647
|
-
範例 [examples/signup](examples/signup/signup-form.ts) 就是這樣做,並有真實 Chrome 測試驗證游標位置不會跳動。
|
|
648
|
-
|
|
649
|
-
## 效能
|
|
650
|
-
|
|
651
|
-
量測環境:真實 Chrome(WSL),每項只有一個文字洞,只計腳本執行時間(不含版面與繪製),單次量測,請當成數量級參考。
|
|
652
|
-
|
|
653
|
-
| 清單大小 | 首次建立 | 資料完全相同 | 只改 1 項 | 反轉順序 |
|
|
654
|
-
|---|---|---|---|---|
|
|
655
|
-
| 1,000 | 約 11 ms | 約 0.6 ms | 約 0.4 ms | 約 1.3 ms |
|
|
656
|
-
| 5,000 | 約 23–34 ms | 約 2 ms | 約 0.9 ms | 約 6–8 ms |
|
|
657
|
-
| 10,000 | 約 65 ms | 約 9 ms | 約 2 ms | 約 19 ms |
|
|
658
|
-
|
|
659
|
-
「資料完全相同」是 `render` 每次都重跑整個清單的成本:文字節點值沒變就不寫,所以很便宜。一個畫面的預算約 16 ms,上萬項再每次按鍵都重跑就會吃緊,請用 `changed` 略過、`skipUnchanged`,或把清單拆小。事件委派不會隨項目數增加監聽器。
|
|
204
|
+
新增、刪除、排序與切換都透過 `this.set()`;清單會保留還在的 task 的元素(所以核取方塊的焦點不會丟)、為新的 task 建立元素、移除消失的,並只對換成新物件的項目設定 `task`。`tsc` 會檢查指派的項目是否符合 `<task-row>` 的 `task` prop 並帶有 `id`。
|
|
660
205
|
|
|
661
206
|
## 範例
|
|
662
207
|
|
|
663
208
|
- [examples/signup](examples/signup/signup-form.ts):註冊表單。三個元件組合(`signup-form` → `form-field`、`entry-row`),涵蓋 `props` 往下、`emits` 往上、清單內的子元件(`hit.key`)、`derive` 驗證、表單輸入不跳游標;對應的真實 Chrome 測試在 [browser/signup.test.ts](browser/signup.test.ts)。
|
|
664
209
|
- [examples/user-card](examples/user-card.ts):最小範例,涵蓋 `state` 自動綁定、`data-on-click`、清單、slot。
|
|
665
210
|
- [examples/todo](examples/todo/todo-app.ts):完整的待辦清單,涵蓋 `state` 與 `derive`、`data-on-*` 事件委派(含清單項目的 `hit.key`)、篩選、空狀態、`aria-pressed`、`skipUnchanged`;有對應的真實 Chrome 端對端測試([browser/todo.test.ts](browser/todo.test.ts))。
|
|
211
|
+
- [examples/recipes](examples/recipes/contact-app.ts):[Recipes](https://github.com/Pixmi/nativele/blob/main/docs/recipes.md) 指南使用的聯絡人畫面,涵蓋共用 store 的載入與錯誤狀態、篩選清單、帶表單驗證的對話框,以及有型別的 `hit.key`;對應的真實 Chrome 測試是 [browser/recipes.test.ts](browser/recipes.test.ts)。
|
|
666
212
|
|
|
667
|
-
##
|
|
668
|
-
|
|
669
|
-
```ts
|
|
670
|
-
const store = createStore({ count: 0 });
|
|
671
|
-
store.get();
|
|
672
|
-
store.set({ count: 1 });
|
|
673
|
-
store.set((s) => ({ count: s.count + 1 }));
|
|
674
|
-
const off = store.subscribe((s) => console.log(s)); // 回傳取消函式
|
|
675
|
-
```
|
|
676
|
-
|
|
677
|
-
`this.bind` 也接受只有 `get` 與 `subscribe` 的唯讀來源。沒有依賴追蹤,`set` 後所有訂閱者都會被呼叫;值以 `Object.is` 相同時不通知。它是不使用 `state` 選項時的小工具,你也可以完全不用,自己管理狀態再直接寫入 `refs` / `text`。需要自動綁定與 `render` 時請改用上面的 `state`。
|
|
678
|
-
|
|
679
|
-
## 不用元件的範本
|
|
680
|
-
|
|
681
|
-
`tpl.instantiate()` 不需要自訂元素就能建立範本,並回傳與元件相同、有型別的操作介面:`root`(`DocumentFragment`)、`refs`、`lists`、`text`、`show`。適合在程式裡建立的小片段,例如加進清單的選單項目:
|
|
682
|
-
|
|
683
|
-
```ts
|
|
684
|
-
import itemTpl from "./menu-item.html.js";
|
|
685
|
-
|
|
686
|
-
function menuItem(label: string): HTMLLIElement {
|
|
687
|
-
const view = itemTpl.instantiate();
|
|
688
|
-
view.text.label = label;
|
|
689
|
-
view.refs.button.addEventListener("click", () => select(label));
|
|
690
|
-
return view.rootElement; // 型別由範本第一層的標籤決定
|
|
691
|
-
}
|
|
692
|
-
```
|
|
693
|
-
|
|
694
|
-
`rootElement` 是範本第一層唯一的元素(第一層的 `<style>` 不算);只有範本剛好一個根元素時型別上才有它,所以範本日後多出第二個根元素時,`tsc` 會在使用處報錯。
|
|
695
|
-
|
|
696
|
-
這種用法沒有宿主元素,所以 `data-on-*` 不會生效(請在 `refs` 上自行監聽),`<slot>` 也不會投影;需要這些時請用元件。
|
|
697
|
-
|
|
698
|
-
## 清單
|
|
699
|
-
|
|
700
|
-
填清單有兩種方式。項目有自己的東西(狀態、事件、內層清單)就用元件;只是幾個純元素組成的小項目就用 `replace()`。
|
|
701
|
-
|
|
702
|
-
### 項目是元件:`items = ...`
|
|
703
|
-
|
|
704
|
-
在 `<template data-each>` 上寫出識別項目的欄位名稱與接收項目的屬性名稱,項目是一個自訂元素:
|
|
705
|
-
|
|
706
|
-
```html
|
|
707
|
-
<ul>
|
|
708
|
-
<template data-each="todos" data-key="id" data-prop="todo">
|
|
709
|
-
<todo-row role="listitem" data-on-toggled="toggle"></todo-row>
|
|
710
|
-
</template>
|
|
711
|
-
</ul>
|
|
712
|
-
```
|
|
713
|
-
|
|
714
|
-
```ts
|
|
715
|
-
render(view, changed) {
|
|
716
|
-
if (changed.has("todos")) this.lists.todos.items = view.todos;
|
|
717
|
-
}
|
|
718
|
-
toggle(_e: Event, hit: EventHit) { /* hit.key 就是該項目的 id */ }
|
|
719
|
-
```
|
|
720
|
-
|
|
721
|
-
指派 `items` 時:key 還在的項目保留原本的元素(瀏覽器支援時用 `moveBefore` 搬動,所以焦點與狀態不會丟)、新 key 建立元素、消失的 key 移除,並對「與上次不是同一個物件」的項目設定元素的 `todo` 屬性。請用不可變更新:就地修改已指派過的物件欄位再指派同一個物件,項目元件不會更新,因為 props 以參考比較;請改指派新物件,例如 `{ ...todo, done: true }`。項目元件在自己的 `props` 宣告 `todo`,愛怎麼顯示都可以,用 `emit` 通知父元件,父元件在處理方法裡讀 `hit.key`。讀取 `items` 會回傳最近一次指派的內容。`tsc` 會檢查這個指派:每個項目要符合 `<todo-row>` 宣告的 `todo` 屬性型別並帶有 `data-key` 欄位(`id`),項目元件必須宣告 `data-prop` 指定的屬性;沒有 `data-key` 與 `data-prop` 的清單不接受指派。這要靠元件註冊在 `ts-dom-tags.ts`(掃描目錄內有匯出的 `define("todo-row", TodoRow)`)。元件定義在掃描目錄內、但類別沒有匯出,或 `ts-dom-tags.ts` 還沒列出它時,指派會是型別錯誤並說明原因(`itemComponentClassNotExported`、`itemComponentMissingFromTagMap`);只有掃描目錄以外的元件,才接受任何帶有 `id` 欄位的項目。執行期則在指派的不是物件陣列、項目沒有字串或數字的 `id`、兩個項目 key 重複、或範本沒有 `data-key` 與 `data-prop` 時丟錯。
|
|
722
|
-
|
|
723
|
-
在 `<ul>` 或 `<ol>` 裡,項目元件要加 `role="listitem"`,因為自訂元素不是 `<li>`;無障礙檢查(`list-structure`)會要求這點。`data-each` 刻意不能巢狀:一個項目就是一個元件,項目裡的清單就是該元件自己的 `data-each`。這樣每套範本都只有一層,有自己的 `refs`、`text` 與事件處理。
|
|
724
|
-
|
|
725
|
-
### 項目是純元素:`replace()`
|
|
726
|
-
|
|
727
|
-
```html
|
|
728
|
-
<ul>
|
|
729
|
-
<template data-each="tags">
|
|
730
|
-
<li><span>{{ label }}</span> <b data-if="hot">🔥</b></li>
|
|
731
|
-
</template>
|
|
732
|
-
</ul>
|
|
733
|
-
```
|
|
734
|
-
|
|
735
|
-
```ts
|
|
736
|
-
this.lists.tags.replace(
|
|
737
|
-
tags,
|
|
738
|
-
(t) => t.id, // key
|
|
739
|
-
(t, { text, refs, show, el }) => {
|
|
740
|
-
text.label = t.label;
|
|
741
|
-
show("hot", t.hot === true);
|
|
742
|
-
},
|
|
743
|
-
);
|
|
744
|
-
```
|
|
745
|
-
|
|
746
|
-
| 方法 | 說明 |
|
|
747
|
-
|---|---|
|
|
748
|
-
| `replace(items, key, update, opts?)` | keyed 對照:刪除多餘、建立缺少、依序重排,並對每項呼叫 `update`。`opts.skipUnchanged: true` 時,項目參照與上次相同就略過 `update`(預設關閉:只在你用不可變更新時才安全,直接修改物件的話畫面會停在舊值) |
|
|
749
|
-
| `upsert(key, item, update)` | 新增(放最後)或更新單一項目 |
|
|
750
|
-
| `remove(key)` | 移除單一項目 |
|
|
751
|
-
| `clear()` | 清空 |
|
|
752
|
-
| `has(key)` / `get(key)` / `size` | 查詢 |
|
|
753
|
-
|
|
754
|
-
`replace` 只做一維的 key 對照,既有項目的 DOM 節點會被保留與移動,不會比對子樹。項目內容完全由你的 `update` 決定。
|
|
755
|
-
|
|
756
|
-
注意:
|
|
757
|
-
|
|
758
|
-
- 項目內的 `data-ref`、洞、`data-if` 屬於該項目,不會出現在元件層級的 `refs` / `text`。
|
|
759
|
-
- `data-if` 不能放在項目的根元素上,請多包一層。
|
|
760
|
-
- `data-each` 不能巢狀,要分成兩套模板:把內層清單放進自己的 `.html`(有自己 `data-each` 的子元件),在外層項目裡使用該元件的標籤。
|
|
761
|
-
- `<slot>` 不能放在項目內。
|
|
762
|
-
|
|
763
|
-
### 清單更新的順序
|
|
213
|
+
## 文件
|
|
764
214
|
|
|
765
|
-
|
|
215
|
+
完整的指南只有英文版(可以用瀏覽器的翻譯功能閱讀):
|
|
766
216
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
-
|
|
770
|
-
-
|
|
771
|
-
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
```
|
|
776
|
-
|
|
777
|
-
- 重新排序:清單(`data-each`)在瀏覽器支援時用 `moveBefore` 搬動項目,所有元件都接受這種搬動而不會被斷開再連上,所以被搬動的項目保有焦點、捲動位置與訂閱。
|
|
778
|
-
|
|
779
|
-
## 檢查
|
|
780
|
-
|
|
781
|
-
四道檢查,互補。**每一則訊息都有穩定的規則代號,並說明哪裡錯、為什麼、怎麼修**;`npx ts-dom rules` 列出全部規則。
|
|
782
|
-
|
|
783
|
-
1. **codegen 診斷**(`ts-dom gen` 與 Vite 外掛,帶行列號,有錯誤時中斷、不寫檔):重複的 `data-ref`、`data-each` 沒有單一根元素、`data-each` 不在 `<template>` 上、`data-each` 巢狀、`data-key` 與 `data-prop` 不完整或不合法、有 `data-key` 的清單其項目不是單一自訂元素、`data-if` 在項目根元素、`<slot>` 在項目內、`<style>` 不在第一層、不合法的洞、屬性內的洞、不合法的 `data-on-*`(含與 DOM 或 ts-dom 成員同名),範本內的 `<html>`、`<head>`、`<body>`,以及會執行程式碼或被 Trusted Types 擋下的標記:`<script>`、`on*` 屬性、`srcdoc`、`javascript:` 網址。
|
|
784
|
-
2. **tsc**:TypeScript 使用了 HTML 沒有宣告的名稱就報錯(`this.refs.nope`、`this.show("nope", true)`);`state`、`props`、`derive`、`emits` 與 HTML 之間的所有約束(見「資料流」);子元件 prop 的名稱與型別;`define()` 檢查 `data-on-*` 的方法存在。
|
|
785
|
-
3. **執行期**:`Component()` 與元件在定義、連接與使用時,對同樣的問題丟出帶 `[規則代號]` 與修正方式的錯誤。
|
|
786
|
-
4. **lint**(`ts-dom/unused-markup`、`ts-dom/data-flow`、`ts-dom/a11y`):HTML 宣告了卻沒被使用的標記(錯誤,附 HTML 位置)、資料流建議與子元件契約(警告)、ARIA(警告)。
|
|
787
|
-
|
|
788
|
-
`unused-markup` 的規則:
|
|
789
|
-
|
|
790
|
-
| 代號 | 偵測 |
|
|
791
|
-
|---|---|
|
|
792
|
-
| `unused-ref` | `data-ref` 沒被元件使用 |
|
|
793
|
-
| `unused-if` | `data-if` 沒被切換(`data-if` 預設隱藏,所以內容永遠不會出現) |
|
|
794
|
-
| `unused-text` | `{{ 洞 }}` 沒被填入,會顯示空白 |
|
|
795
|
-
| `unused-list` | `data-each` 沒被渲染,清單永遠是空的 |
|
|
796
|
-
| `unused-item-name` | 清單項目內的名稱沒被引用 |
|
|
797
|
-
| `missing-handler` | `data-on-*` 引用的方法,類別沒有定義 |
|
|
798
|
-
| `no-importer` | 沒有任何 TypeScript 檔匯入這個範本 |
|
|
799
|
-
|
|
800
|
-
lint 以 `class X extends Component(tpl)` 的**類別本體**為單位比對;使用 `state` / `props` / `derive` 選項時,`{{ }}` 與 `data-if` 由自動綁定處理,不要求在程式中出現,但 `data-on-*` 引用的方法必須存在(若程式用 `refs[k]`、`text[k]`、`lists[k]` 這類動態存取,無法得知用到哪些名稱,會保守地視為全部都有用到),所以同一個檔案匯入多個範本也不會互相蓋掉:
|
|
801
|
-
|
|
802
|
-
```ts
|
|
803
|
-
import listTpl from "./list.html.js"; // data-ref="title"
|
|
804
|
-
import cardTpl from "./card.html.js"; // data-ref="title"(Card 從未使用)
|
|
805
|
-
class List extends Component(listTpl) { connected() { this.refs.title.hidden = true } }
|
|
806
|
-
class Card extends Component(cardTpl) {} // → 回報 card.html 的 title 未被使用
|
|
807
|
-
```
|
|
808
|
-
|
|
809
|
-
注意:refs、text 等應由元件自己的 class 使用。在 class 外部透過實例存取(例如測試)不會被計入,測試目錄請關閉這條規則。找不到對應 class(例如用 helper 包裝 `Component`)時,退回整份檔案比對。
|
|
810
|
-
|
|
811
|
-
## ARIA / 無障礙檢查
|
|
812
|
-
|
|
813
|
-
預設**關閉**。開啟後,會對範本中建議加上 ARIA 或標籤的地方產生**警告**(不是錯誤)。有三種開法,共用同一份檢查:
|
|
814
|
-
|
|
815
|
-
```ts
|
|
816
|
-
// 1. lint:編輯器與 vp lint 都會顯示,警告回報在 import 那一行(訊息內含 html 的行列號)
|
|
817
|
-
lint: { rules: { "ts-dom/a11y": ["warn", { ignore: ["img-alt"] }] } }
|
|
818
|
-
|
|
819
|
-
// 2. 建置:dev / build 時在終端機顯示
|
|
820
|
-
plugins: [tsDom({ a11y: true })] // 或 { a11y: { ignore: ["img-alt"] } }
|
|
821
|
-
```
|
|
822
|
-
|
|
823
|
-
```sh
|
|
824
|
-
# 3. CLI
|
|
825
|
-
npx ts-dom lint --a11y [--strict]
|
|
826
|
-
```
|
|
827
|
-
|
|
828
|
-
`ignore` 填規則代號,可關閉個別檢查:
|
|
829
|
-
|
|
830
|
-
| 代號 | 檢查 |
|
|
831
|
-
|---|---|
|
|
832
|
-
| `img-alt` | `<img>` 需要 `alt`(裝飾性圖片用 `alt=""`) |
|
|
833
|
-
| `control-name` | `<button>`、`<a href>`、`<summary>` 需要可讀名稱(文字、`{{ }}`、`<slot>`、`aria-label` 或 `aria-labelledby`) |
|
|
834
|
-
| `form-label` | `<input>`、`<select>`、`<textarea>` 需要 `<label for>`、被 `<label>` 包住,或 `aria-label` |
|
|
835
|
-
| `iframe-title` | `<iframe>` 需要 `title` |
|
|
836
|
-
| `dialog-name` | `<dialog>` 需要 `aria-label` 或 `aria-labelledby` |
|
|
837
|
-
| `heading-empty` | `h1`–`h6` 不可為空 |
|
|
838
|
-
| `aria-role` | `role` 必須是有效的 ARIA 角色 |
|
|
839
|
-
| `aria-attr` | `aria-*` 必須是有效的 ARIA 屬性 |
|
|
840
|
-
| `role-tabindex` | 非原生可聚焦元素若有互動 role(button、tab、checkbox…),需要 `tabindex` |
|
|
841
|
-
| `tabindex-positive` | 不要使用大於 0 的 `tabindex` |
|
|
842
|
-
| `hidden-focusable` | 可聚焦元素不可設 `aria-hidden="true"` |
|
|
843
|
-
| `label-for` | `<label for>` 必須指向範本內存在的 `id` |
|
|
844
|
-
| `id-in-list` | `data-each` 項目內的 `id` 會隨項目重複,請改用 `data-ref` 或 class |
|
|
845
|
-
| `duplicate-id` | `id` 在範本內不可重複 |
|
|
846
|
-
| `aria-idref` | `aria-labelledby`、`describedby`、`controls`、`owns` 等參照的 id 必須存在於範本內(指向範本外的元素時可用 `ignore` 關閉) |
|
|
847
|
-
| `heading-order` | 標題層級不可跳級(`h1` 後直接 `h3`),以範本內的文件順序判斷 |
|
|
848
|
-
| `table-header` | `<table>` 需要 `<th>`;版面用表格請設 `role="presentation"` |
|
|
849
|
-
| `list-structure` | `<ul>`/`<ol>` 的子元素(含 `data-each` 項目的根元素)必須是 `<li>`;`<li>` 必須在 `<ul>`、`<ol>`、`<menu>` 內 |
|
|
850
|
-
| `landmark-label` | 同一個範本內有多個 `<nav>` 或 `<aside>` 時,需要用 `aria-label` 區分 |
|
|
851
|
-
| `redundant-role` | `role` 與元素原本的語意相同,例如 `<button role="button">` |
|
|
852
|
-
| `click-semantics` | `data-on-click` 應放在可操作的元素上(`button`、`a[href]`、`label`,或有互動 role 加 `tabindex`),否則鍵盤與輔助技術無法使用 |
|
|
853
|
-
|
|
854
|
-
為了避免大量誤報,有 `data-ref` 的元素視為「由 TS 管理」,會略過 `img-alt`、`control-name`、`form-label`、`iframe-title`、`dialog-name`、`heading-empty`、`role-tabindex`、`table-header`、`landmark-label`、`aria-idref` 這些「名稱或參照可能在 TS 設定」的檢查。
|
|
855
|
-
|
|
856
|
-
這是靜態檢查,只看 HTML 範本:不會分析 TS 在執行時加上的屬性,也不涵蓋色彩對比、焦點管理、鍵盤互動等需要實際渲染才能判斷的項目,不能取代 axe 之類的工具與人工測試。
|
|
857
|
-
|
|
858
|
-
## 專案結構
|
|
859
|
-
|
|
860
|
-
```
|
|
861
|
-
src/lib/
|
|
862
|
-
component.ts Component 基底類別、define
|
|
863
|
-
list.ts keyed 清單
|
|
864
|
-
cond.ts data-if
|
|
865
|
-
text.ts {{ }} 洞
|
|
866
|
-
slots.ts Light DOM 的 slot
|
|
867
|
-
style.ts Light 模式的樣式注入、scoped
|
|
868
|
-
store.ts createStore
|
|
869
|
-
template.ts Template / Schema 型別
|
|
870
|
-
codegen.ts parse5 解析、診斷、產生 .html.ts
|
|
871
|
-
vite-plugin.ts 選用的 Vite 外掛(@nativele/ts-dom/vite)
|
|
872
|
-
lint.ts 未使用標記與資料流檢查
|
|
873
|
-
reserved.ts 保留名稱(與 DOM、ts-dom 成員衝突)
|
|
874
|
-
llms.txt 給 AI agent 的精簡參考(隨套件發佈)
|
|
875
|
-
a11y.ts ARIA / 無障礙檢查
|
|
876
|
-
oxlint-plugin.ts oxlint JS 外掛(@nativele/ts-dom/oxlint)
|
|
877
|
-
src/cli.ts ts-dom gen / lint
|
|
878
|
-
examples/ user-card 與待辦清單(todo)範例,demo 入口
|
|
879
|
-
test/ 單元測試(happy-dom)
|
|
880
|
-
browser/ 真實 Chrome 測試與 fixture
|
|
881
|
-
```
|
|
882
|
-
|
|
883
|
-
## 開發本專案
|
|
884
|
-
|
|
885
|
-
| 指令 | 說明 |
|
|
886
|
-
|---|---|
|
|
887
|
-
| `npm run dev` | 啟動 demo(`vp dev`) |
|
|
888
|
-
| `npm run build` | 建置 demo 到 `demo-dist/` |
|
|
889
|
-
| `npm run build:lib` | 建置函式庫到 `dist/` |
|
|
890
|
-
| `npm run smoke` | 打包後在裝了 Vite 的全新專案驗證 `gen → tsc → lint → build` |
|
|
891
|
-
| `npm run smoke:no-bundler` | 打包後在**只裝 typescript** 的全新專案驗證 `gen → tsc → 瀏覽器執行` |
|
|
892
|
-
| `npm run verify:pack` | 建置並以 `publint`、`attw` 檢查發佈設定(`npm pack` 前會自動執行) |
|
|
893
|
-
| `npm run gen` | 產生範例與測試的 `.html.ts` |
|
|
894
|
-
| `npm run check` | `gen` 後執行 `tsc` |
|
|
895
|
-
| `npm test` | 單元測試(happy-dom) |
|
|
896
|
-
| `npm run test:browser` | 在真實 Chrome 中執行的測試(見下) |
|
|
897
|
-
| `npm run lint` | `vp lint`,含 `ts-dom/unused-markup` |
|
|
898
|
-
| `npm run lint:html` | 同一套檢查的 CLI 版本 |
|
|
899
|
-
|
|
900
|
-
repo 內的範例以 `vite.config.ts` 的 alias 直接指向 `src/lib`,不需先打包。
|
|
901
|
-
|
|
902
|
-
### 瀏覽器測試
|
|
903
|
-
|
|
904
|
-
`npm run test:browser` 會啟動 `vp dev`,以 `playwright-core` 驅動系統安裝的 Chrome(不下載瀏覽器),驗證 happy-dom 無法涵蓋的行為:原生 `@scope`、解析器建立元素的 slot 分配、`MutationObserver` 動態 slot、Shadow DOM 的原生 slot 與樣式隔離。
|
|
905
|
-
|
|
906
|
-
| 環境變數 | 說明 |
|
|
907
|
-
|---|---|
|
|
908
|
-
| `CHROME_BIN` | Chrome 路徑,預設 `/usr/bin/google-chrome` |
|
|
909
|
-
| `TSDOM_CHROME_HOME` | Chrome 使用的 HOME,預設 `/tmp/chome`(WSL 或唯讀家目錄下避免 crashpad 寫入失敗) |
|
|
910
|
-
|
|
911
|
-
測試固定使用 `--no-sandbox --no-zygote`,適合 WSL 與容器環境;一般桌面環境也可執行。
|
|
912
|
-
|
|
913
|
-
預設只跑 Chromium,CI 也只跑 Chromium:這個函式庫是 TS 工具,靠的是 DOM 標準 API,不是瀏覽器之間差異很大的 CSS 渲染。若想在 Firefox 上試跑,可用 `BROWSER=firefox PLAYWRIGHT_BROWSERS_PATH=<路徑> npm run test:browser`(需先 `npx playwright-core install firefox`;Firefox 157 已驗證全數通過)。WebKit 未驗證。
|
|
914
|
-
|
|
915
|
-
## 設計決定
|
|
916
|
-
|
|
917
|
-
這些是刻意的取捨,不是缺少的功能:
|
|
918
|
-
|
|
919
|
-
- **`{{ }}` 只能是單一識別字**:路徑與表達式會讓 HTML 擁有第二份資料形狀,也會滑向模板語言。需要計算請在 TS 完成後填入具名洞。
|
|
920
|
-
- **沒有屬性綁定**:用 `data-ref` 取得元素,再在 TS 設定屬性。
|
|
921
|
-
- **`<slot>` 不可放在 `data-each` 項目內**:項目會重複,但使用者提供的內容只有一份,原生 Shadow DOM 的語意也是只有第一個同名 slot 會拿到內容。要讓每個項目都有各自的內容,需要 Vue scoped slot 那種「把項目資料傳給插槽」的機制,那會需要表達式。請改在 `update` 回呼中用 TS 填入,或把項目內容做成另一個元件。
|
|
922
|
-
- **資料流只有一個方向**:state 自己改、props 由父層改、事件往上;`emit` 預設不冒泡,要跨層就明確處理或用共享 store,不做隱式的跨層傳遞。
|
|
923
|
-
- **資料只有一個入口、畫面只有一個地方**(`set` 與 `render`):「資料怎麼變成畫面」集中在 `render`,要追蹤資料變化只需看 `set` 的呼叫。
|
|
924
|
-
- **同步渲染,不做隱式非同步合併**:`set` 之後立刻讀 DOM 一定是新的,測試也不需要等待;要合併請明確用 `batch`。
|
|
925
|
-
- **沒有細粒度反應式系統**:`derive` 每次重新計算、`render` 每次整體重跑,換來的是行為可預測、沒有依賴追蹤的魔法。
|
|
926
|
-
- **`data-if` 預設隱藏**:狀態只在 TS,沒說要顯示就不顯示。
|
|
927
|
-
- **`data-if` 不可放在項目根元素、`data-each` 不可巢狀**:根元素被移除會讓清單失去排序錨點;一套模板只有一層清單,巢狀的清單就是第二套模板(子元件),讓每套模板的 `refs`、`text` 與事件處理都在單一範圍內。
|
|
928
|
-
|
|
929
|
-
## 限制
|
|
930
|
-
|
|
931
|
-
- 在瀏覽器直接執行(不用 bundler)時,需自行提供 import map 對應 `ts-dom`,且相對匯入需寫 `.js` 副檔名(NodeNext 慣例)。
|
|
932
|
-
- `data-on-*` 的事件名稱要小寫;不冒泡的事件只處理 target 本身。
|
|
933
|
-
- 子元件的自動 import 與標籤型別只掃描 `gen` 指定的目錄;第三方或目錄外的元件要自己 import,也要自己宣告 `HTMLElementTagNameMap`。`define` 的類別沒有 `export` 時不會有標籤型別。
|
|
934
|
-
- 事件處理方法收到的 `CustomEvent` 需要用 `EmitEvent<子元件, "事件名">` 轉型,HTML 屬性無法帶型別。
|
|
935
|
-
- Shadow 模式下 `slotchange(name)` 方法不會被呼叫,請用原生 `slotchange` 事件。
|
|
936
|
-
- `define("tag", Class)` 的掃描是文字比對(會略過字串與註解),不是完整的程式分析;HTML 層級的子元件契約檢查也一樣,只讀得到 `Component(tpl, { props, emits })` 這種字面寫法。
|
|
937
|
-
- `props` 只有預設值為 string / number / boolean 時才能用 HTML 屬性傳入。
|
|
938
|
-
- 自動綁定只涵蓋文字洞與 `data-if`,清單與屬性要在 `render` 寫。
|
|
939
|
-
- 版本 0.x,API 仍可能改動(例如 `Update` 回呼簽章已改過兩次),版本策略見 [CHANGELOG.md](CHANGELOG.md)。
|
|
940
|
-
- 沒有 SSR 或 hydration。
|
|
941
|
-
- 沒有屬性綁定(刻意不做,請用 `refs`)。
|
|
942
|
-
- 未使用標記的 lint 以類別本體文字比對,對清單項目內的名稱較寬鬆(只看識別字)。
|
|
943
|
-
- `<style scoped>` 在不支援 `@scope` 的瀏覽器退回 CSS nesting,需要瀏覽器支援 nesting(2023 年起為主流)。
|
|
944
|
-
- ARIA 檢查只涵蓋靜態 HTML 的常見項目(見上方清單),不是完整的無障礙稽核。
|
|
945
|
-
- 瀏覽器測試以 Chromium 為準;Firefox 157 手動驗證通過,WebKit(Safari)未驗證。
|
|
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)
|