@nativele/ts-dom 0.5.2 → 0.5.4
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 +55 -0
- package/README.md +4 -2
- package/README.zh-TW.md +4 -2
- package/dist/{a11y-CV3nBgBn.mjs → a11y-CiEJj_e6.mjs} +74 -28
- package/dist/cli.mjs +182 -36
- package/dist/{lint-Bwytj7L8.mjs → lint-Did4w-NL.mjs} +2 -2
- package/dist/oxlint-plugin.mjs +2 -2
- package/dist/vite-plugin.mjs +21 -26
- package/llms.txt +4 -2
- package/package.json +1 -1
- package/skills/README.md +3 -2
- package/skills/nativele-angular/SKILL.md +1 -3
- package/skills/nativele-architecture/SKILL.md +541 -0
- package/skills/nativele-integration/SKILL.md +4 -6
- package/skills/nativele-react/SKILL.md +8 -12
- package/skills/nativele-svelte/SKILL.md +8 -12
- package/skills/nativele-testing/SKILL.md +1 -1
- package/skills/nativele-vite/SKILL.md +1 -1
- package/skills/nativele-vue/SKILL.md +2 -2
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nativele-architecture
|
|
3
|
+
description: "Lay out an app built with Nativele that has several pages: feature folders with their components, stores and tests, shared components and app-wide stores, pages as custom elements that a router mounts (the Navigation API with URLPattern, or universal-router), lazy-loaded page modules, one scanned folder for the generated files, and which behavior goes to unit, real-browser and end-to-end tests. Use when starting such an app or restructuring one, or when a project depends on @nativele/ts-dom and universal-router."
|
|
4
|
+
metadata:
|
|
5
|
+
tier: official
|
|
6
|
+
nativele: ">=0.5.3 <0.6.0"
|
|
7
|
+
verified: "2026-11-05"
|
|
8
|
+
tools: "vite@8.3.6 typescript@7.0.2 vitest@5.1.0 happy-dom@21.0.4 @vitest/browser-playwright@5.1.0 playwright@1.64.1 @playwright/test@1.64.1 create-vite@9.3.4 universal-router@10.0.3"
|
|
9
|
+
author: "Nativele maintainers"
|
|
10
|
+
detects: "universal-router"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Nativele app architecture
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
- A Vite app that already uses Nativele (`@nativele/ts-dom`), set up as in `nativele-vite` (started with `npm create vite@latest -- --template vanilla-ts`), that has or will have several pages: you are starting its structure, or restructuring a flat `src/nv/` folder into features.
|
|
18
|
+
- Signals: more than one page or URL, a component folder that has grown past a screen of files, the same component or store wanted by two parts of the app, a router dependency such as `universal-router`.
|
|
19
|
+
- The reasoning behind every step (why features, where state lives, why one scanned folder) is in the architecture guide: https://github.com/Pixmi/nativele/blob/main/docs/architecture.md. This Skill is the procedure.
|
|
20
|
+
- Not for writing components (`nativele-core`), setting up Vite (`nativele-vite`), choosing or connecting a state library (`nativele-state-management`), or writing the tests themselves (`nativele-testing`); this Skill uses all four and links to them.
|
|
21
|
+
- Not for an app whose pages are rendered by a UI framework (React, Vue): there the framework's router mounts the pages; see that framework's Skill.
|
|
22
|
+
|
|
23
|
+
`ts-dom doctor` suggests this Skill when `universal-router` is a dependency. The recommended router needs no dependency, so there is nothing else in `package.json` that tells a multi-page app from a single widget; detecting `@nativele/ts-dom` itself would suggest it to every project, which `nativele-core` already covers.
|
|
24
|
+
|
|
25
|
+
## Versions
|
|
26
|
+
|
|
27
|
+
| Tool | Verified version |
|
|
28
|
+
|---|---|
|
|
29
|
+
| @nativele/ts-dom | 0.5.3 |
|
|
30
|
+
| vite | 8.3.6 |
|
|
31
|
+
| typescript | 7.0.2 |
|
|
32
|
+
| create-vite (`vanilla-ts` template, the starting point) | 9.3.4 |
|
|
33
|
+
| vitest | 5.1.0 |
|
|
34
|
+
| happy-dom | 21.0.4 |
|
|
35
|
+
| @vitest/browser-playwright | 5.1.0 |
|
|
36
|
+
| playwright | 1.64.1 (drives the installed Chrome; no browser is downloaded) |
|
|
37
|
+
| @playwright/test | 1.64.1 |
|
|
38
|
+
| universal-router | 10.0.3 (checked by hand with the example's tests, see step 5; the example project does not run it) |
|
|
39
|
+
|
|
40
|
+
Verified on 2026-11-05 with Node 24 and Google Chrome 148, with the example project in the Nativele repository (https://github.com/Pixmi/nativele/tree/main/skills/nativele-architecture/example; it is not in the npm package), which CI runs again when the example or the library changes and once a month. The router relies on the Navigation API and `URLPattern`, verified in Chrome only. With another major version of any tool above, treat every step as unverified.
|
|
41
|
+
|
|
42
|
+
## Set-up
|
|
43
|
+
|
|
44
|
+
1. **Make the folders** under `src/`:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
src/main.ts # imports the shell, starts the router
|
|
48
|
+
src/app/ # the router, the shell, stores of the app itself
|
|
49
|
+
src/shared/ # components and stores used by more than one feature
|
|
50
|
+
src/shared/stores/
|
|
51
|
+
src/features/<feature>/ # each feature's pages, components, store and tests
|
|
52
|
+
e2e/ # tests of the built app
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Restructuring: move each page component and the components only it uses into its feature folder, each component used by two features into `src/shared/`, and the header or navigation into `src/app/`. Templates need no change (the import of a child component is generated); fix the relative `import` paths in the `.ts` files, and `ts-dom check src` (step 2) reports the ones you missed as `TS2307`. A feature never imports another feature: move what both need to `src/shared/`.
|
|
56
|
+
|
|
57
|
+
2. **Scan one folder, `src`, everywhere.** In `vite.config.ts`, `tsDom({ dirs: ["src"] })`; in every script and command, `ts-dom check src` and `ts-dom doctor src`. One folder gives one `ts-dom-tags.ts` and one argument for every command (limitation 1). When the app used another folder before (`src/nv` from `nativele-vite`), delete the `ts-dom-tags.ts` left in it, and the folder itself once its components have moved (Troubleshooting).
|
|
58
|
+
|
|
59
|
+
3. **Set `tsconfig.json`'s `include`** to `["src", "e2e"]`: `src` covers the app, the tests next to the code and `ts-dom-tags.ts`, and `e2e` the end-to-end specs, so `ts-dom check src` type-checks all of them. Keep `"strict": true` from `nativele-vite`.
|
|
60
|
+
|
|
61
|
+
4. **Put the shell and the outlet in `index.html`**, and start the router from `src/main.ts`:
|
|
62
|
+
|
|
63
|
+
```html
|
|
64
|
+
<app-nav></app-nav>
|
|
65
|
+
<main id="outlet"></main>
|
|
66
|
+
<script type="module" src="/src/main.ts"></script>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// src/main.ts
|
|
71
|
+
import "./app/app-nav.ts";
|
|
72
|
+
import { startRouter } from "./app/router.ts";
|
|
73
|
+
|
|
74
|
+
void startRouter(document.querySelector("#outlet")!);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
5. **Add the router**, `src/app/router.ts` from the Example, with one `page(path, tag, () => import(module), params => props)` per route. Keep three things from it: the page module is imported before `document.createElement(tag)`, the route params are set as props, and `if (signal?.aborted) return;` comes after the import (limitation 3). If the app already has a router, keep it and call the same `mount()` from its route handler. With `universal-router` 10 (`npm i universal-router@10.0.3`) and the History API, keep the imports, `Tag`, `Params` and `mount()`, and replace `Route`, `page()`, `routes`, `loaded()`, `show()` and `startRouter()` with:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import UniversalRouter from "universal-router";
|
|
81
|
+
|
|
82
|
+
interface Match {
|
|
83
|
+
tag: Tag;
|
|
84
|
+
load: () => Promise<unknown>;
|
|
85
|
+
props: object;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function page<K extends Tag>(path: string, tag: K, load: () => Promise<unknown>, props: (params: Params) => Partial<HTMLElementTagNameMap[K]> = () => ({})) {
|
|
89
|
+
return { path, action: ({ params }: { params: Record<string, unknown> }): Match => ({ tag, load, props: props(params as Params) }) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const router = new UniversalRouter<Match | null>(
|
|
93
|
+
[
|
|
94
|
+
page("/", "projects-page", () => import("../features/projects/projects-page.ts")),
|
|
95
|
+
page("/projects/:id", "project-page", () => import("../features/projects/project-page.ts"), (p) => ({ projectId: p.id ?? "" })),
|
|
96
|
+
page("/settings", "settings-page", () => import("../features/settings/settings-page.ts")),
|
|
97
|
+
],
|
|
98
|
+
{ errorHandler: () => null },
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
async function loaded(match: Match): Promise<boolean> {
|
|
102
|
+
try {
|
|
103
|
+
await match.load();
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function show(outlet: Element, url: string, signal?: AbortSignal): Promise<void> {
|
|
111
|
+
const path = new URL(url).pathname;
|
|
112
|
+
const match = await router.resolve(path);
|
|
113
|
+
const ok = match != null && (await loaded(match));
|
|
114
|
+
if (signal?.aborted) return;
|
|
115
|
+
if (match && ok) mount(outlet, match.tag, match.props);
|
|
116
|
+
else if (match) mount(outlet, "route-message", { heading: "This page could not be loaded", detail: "Check the connection and reload the page." });
|
|
117
|
+
else mount(outlet, "route-message", { heading: "Page not found", detail: `Nothing lives at ${path}.` });
|
|
118
|
+
currentPath.set(path);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let pending: AbortController | undefined;
|
|
122
|
+
|
|
123
|
+
export function startRouter(outlet: Element): Promise<void> {
|
|
124
|
+
const go = () => {
|
|
125
|
+
pending?.abort();
|
|
126
|
+
pending = new AbortController();
|
|
127
|
+
return show(outlet, location.href, pending.signal);
|
|
128
|
+
};
|
|
129
|
+
document.addEventListener("click", (e) => {
|
|
130
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
131
|
+
const a = e.target instanceof Element ? e.target.closest("a") : null;
|
|
132
|
+
if (!a || a.target || a.hasAttribute("download") || a.origin !== location.origin) return;
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
history.pushState(null, "", a.href);
|
|
135
|
+
void go();
|
|
136
|
+
});
|
|
137
|
+
addEventListener("popstate", () => void go());
|
|
138
|
+
return go();
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`router.resolve()` returns `undefined` when an action returns nothing, so test the match with `!= null`. With this router, the example's unit tests and its end-to-end tests pass except the one for a browser without the Navigation API (links are always handled by the click handler) and the one that calls `navigation.navigate()`, which passes when it clicks a link instead; its browser router tests use the Navigation API and do not apply.
|
|
143
|
+
|
|
144
|
+
6. **Write each page as a component** in its feature folder: route params arrive as string props (`props: { projectId: "" }`), the page shows what an unknown id means, and it binds its stores in `connected()`. Everything a page must keep after the user leaves goes into a store: leaving removes the page, which ends its `bind` subscriptions, and coming back creates a new element.
|
|
145
|
+
|
|
146
|
+
7. **Put state at the lowest level that holds it** (the table in the guide): a component's `state` for what one component shows; a feature store (`src/features/<feature>/<feature>-store.ts`: `createStore` plus its actions as exported functions and a `seed...()` function that returns fresh initial data, for tests) for what a feature's pages share; an app-wide store in `src/shared/stores/` for what the shell or several features read. For an existing Zustand, Redux or nanostores store, follow `nativele-state-management`.
|
|
147
|
+
|
|
148
|
+
8. **Make shared components take props and emit events** only; they never import a feature or a feature store. The feature decides what an event means (`text-entry` emits `entry-submit`; the projects page adds a project, the project page a task).
|
|
149
|
+
|
|
150
|
+
9. **Add the tests** as in `nativele-testing`, with the patterns for tests next to the code:
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
npm i -D vitest@5.1.0 happy-dom@21.0.4 @vitest/browser-playwright@5.1.0 playwright@1.64.1 @playwright/test@1.64.1
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `src/**/*.test.ts` in happy-dom: store actions, components with props, events and stores, pages given their params as props, and the route table through `show()`.
|
|
157
|
+
- `src/**/*.browser.test.ts` in Chrome: the router with the Navigation API, and moves and focus in reordered lists.
|
|
158
|
+
- `e2e/*.spec.ts` with Playwright Test against `vite preview` of the build: deep links, navigation, back and forward, lazy chunks, data across pages.
|
|
159
|
+
|
|
160
|
+
10. **Set the scripts**:
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
npm pkg set scripts.build="ts-dom check src && vite build"
|
|
164
|
+
npm pkg set scripts.test="vitest run --project unit"
|
|
165
|
+
npm pkg set scripts.test:browser="vitest run --project browser"
|
|
166
|
+
npm pkg set scripts.test:e2e="playwright test"
|
|
167
|
+
npm pkg set scripts.verify="npm run build && ts-dom doctor src && npm test && npm run test:browser && npm run test:e2e"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The build script is for a project whose type check is plain `tsc`: `ts-dom check` generates the files and type-checks. In a framework project, keep the framework's type check (`tsc -b`, `vue-tsc -b`, `svelte-check`) and put `ts-dom gen src &&` in front of it instead (the framework's Skill shows the script). Add `test-results` and `playwright-report` to `.gitignore`, next to `*.html.ts` and `ts-dom-tags.ts`.
|
|
171
|
+
|
|
172
|
+
## Example
|
|
173
|
+
|
|
174
|
+
The example project is a project tracker: a projects list (`/`), a project page with its tasks (`/projects/:id`) and a settings page (`/settings`).
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
index.html package.json tsconfig.json vite.config.ts playwright.config.ts
|
|
178
|
+
src/main.ts
|
|
179
|
+
src/app/router.ts src/app/current-path.ts src/app/app-nav.* src/app/route-message.* src/app/*.test.ts src/app/router.browser.test.ts
|
|
180
|
+
src/shared/text-entry.* src/shared/progress-meter.* src/shared/text-entry.test.ts src/shared/stores/preferences.ts
|
|
181
|
+
src/features/projects/projects-store.ts projects-page.* project-page.* project-card.* task-row.* *.test.ts project-page.browser.test.ts
|
|
182
|
+
src/features/settings/settings-page.* settings-page.test.ts
|
|
183
|
+
e2e/app.spec.ts
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The router (Navigation API and `URLPattern`, no dependency):
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// src/app/router.ts
|
|
190
|
+
import { currentPath } from "./current-path.ts";
|
|
191
|
+
import "./route-message.ts";
|
|
192
|
+
|
|
193
|
+
type Tag = keyof HTMLElementTagNameMap;
|
|
194
|
+
type Params = Record<string, string | undefined>;
|
|
195
|
+
|
|
196
|
+
interface Route {
|
|
197
|
+
pattern: URLPattern;
|
|
198
|
+
tag: Tag;
|
|
199
|
+
load: () => Promise<unknown>;
|
|
200
|
+
props: (params: Params) => object;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function page<K extends Tag>(
|
|
204
|
+
pathname: string,
|
|
205
|
+
tag: K,
|
|
206
|
+
load: () => Promise<unknown>,
|
|
207
|
+
props: (params: Params) => Partial<HTMLElementTagNameMap[K]> = () => ({}),
|
|
208
|
+
): Route {
|
|
209
|
+
return { pattern: new URLPattern({ pathname }), tag, load, props };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const routes: Route[] = [
|
|
213
|
+
page("/", "projects-page", () => import("../features/projects/projects-page.ts")),
|
|
214
|
+
page("/projects/:id", "project-page", () => import("../features/projects/project-page.ts"), (p) => ({ projectId: p.id ?? "" })),
|
|
215
|
+
page("/settings", "settings-page", () => import("../features/settings/settings-page.ts")),
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
function mount<K extends Tag>(outlet: Element, tag: K, props: Partial<HTMLElementTagNameMap[K]>): void {
|
|
219
|
+
const el = document.createElement(tag);
|
|
220
|
+
Object.assign(el, props);
|
|
221
|
+
outlet.replaceChildren(el);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function loaded(route: Route): Promise<boolean> {
|
|
225
|
+
try {
|
|
226
|
+
await route.load();
|
|
227
|
+
return true;
|
|
228
|
+
} catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function show(outlet: Element, url: string, signal?: AbortSignal): Promise<void> {
|
|
234
|
+
const route = routes.find((r) => r.pattern.test(url));
|
|
235
|
+
const ok = route !== undefined && (await loaded(route));
|
|
236
|
+
if (signal?.aborted) return;
|
|
237
|
+
const path = new URL(url).pathname;
|
|
238
|
+
if (route && ok) mount(outlet, route.tag, route.props(route.pattern.exec(url)?.pathname.groups ?? {}));
|
|
239
|
+
else if (route) mount(outlet, "route-message", { heading: "This page could not be loaded", detail: "Check the connection and reload the page." });
|
|
240
|
+
else mount(outlet, "route-message", { heading: "Page not found", detail: `Nothing lives at ${path}.` });
|
|
241
|
+
currentPath.set(path);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function startRouter(outlet: Element): Promise<void> {
|
|
245
|
+
if ("navigation" in window) {
|
|
246
|
+
navigation.addEventListener("navigate", (e) => {
|
|
247
|
+
if (!e.canIntercept || e.hashChange || e.downloadRequest !== null || e.formData !== null) return;
|
|
248
|
+
e.intercept({ handler: () => show(outlet, e.destination.url, e.signal) });
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return show(outlet, location.href);
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
// src/app/current-path.ts: an app store the router sets and the shell binds to mark the current link
|
|
257
|
+
import { createStore } from "@nativele/ts-dom";
|
|
258
|
+
|
|
259
|
+
export const currentPath = createStore(location.pathname);
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`route-message` is a component with the props `heading` and `detail`. A wrong tag or prop in the route table is a `tsc` error, because the types come from `ts-dom-tags.ts`: `TS2345: Argument of type '"projects-pag"' is not assignable to parameter of type 'keyof HTMLElementTagNameMap'`, or `Type 'number' is not assignable to type 'string'` for `projectId`.
|
|
263
|
+
|
|
264
|
+
A feature store and a page of that feature:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
// src/features/projects/projects-store.ts (abridged: the example adds addProject and more seed data)
|
|
268
|
+
import { createStore } from "@nativele/ts-dom";
|
|
269
|
+
|
|
270
|
+
export interface Project { id: string; name: string }
|
|
271
|
+
export interface Task { id: string; projectId: string; title: string; done: boolean }
|
|
272
|
+
export interface ProjectsState { projects: readonly Project[]; tasks: readonly Task[]; nextId: number }
|
|
273
|
+
|
|
274
|
+
export function seedProjects(): ProjectsState {
|
|
275
|
+
return { projects: [{ id: "p1", name: "Website" }], tasks: [{ id: "t1", projectId: "p1", title: "Publish", done: false }], nextId: 2 };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const projects = createStore<ProjectsState>(seedProjects());
|
|
279
|
+
|
|
280
|
+
export function addTask(projectId: string, title: string): void {
|
|
281
|
+
projects.set((s) => ({ ...s, tasks: [...s.tasks, { id: `t${s.nextId}`, projectId, title, done: false }], nextId: s.nextId + 1 }));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function toggleTask(id: string): void {
|
|
285
|
+
projects.set((s) => ({ ...s, tasks: s.tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) }));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function tasksOf(s: ProjectsState, projectId: string): readonly Task[] {
|
|
289
|
+
return s.tasks.filter((t) => t.projectId === projectId);
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
```html
|
|
294
|
+
<!-- src/features/projects/project-page.html -->
|
|
295
|
+
<p><a href="/">All projects</a></p>
|
|
296
|
+
<section data-if="found">
|
|
297
|
+
<h1>{{ name }}</h1>
|
|
298
|
+
<progress-meter data-ref="meter"></progress-meter>
|
|
299
|
+
<text-entry label="New task" action="Add task" data-on-entry-submit="onAdd"></text-entry>
|
|
300
|
+
<ul>
|
|
301
|
+
<template data-each="tasks" data-key="id" data-prop="task">
|
|
302
|
+
<task-row role="listitem" data-on-task-toggle="onToggle"></task-row>
|
|
303
|
+
</template>
|
|
304
|
+
</ul>
|
|
305
|
+
<p data-if="someHidden">{{ hiddenCount }} done tasks are hidden.</p>
|
|
306
|
+
</section>
|
|
307
|
+
<p data-if="missing">There is no project with this id.</p>
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
// src/features/projects/project-page.ts
|
|
312
|
+
import { Component, define } from "@nativele/ts-dom";
|
|
313
|
+
import type { DeepReadonly, EmitEvent, EventHit } from "@nativele/ts-dom";
|
|
314
|
+
import { preferences } from "../../shared/stores/preferences.ts";
|
|
315
|
+
import type { TextEntry } from "../../shared/text-entry.ts";
|
|
316
|
+
import { addTask, projects, tasksOf, toggleTask, type ProjectsState, type Task } from "./projects-store.ts";
|
|
317
|
+
import tpl from "./project-page.html.js";
|
|
318
|
+
|
|
319
|
+
type View = DeepReadonly<{ projectId: string; data: ProjectsState; showDone: boolean }>;
|
|
320
|
+
|
|
321
|
+
const project = (v: View) => v.data.projects.find((p) => p.id === v.projectId);
|
|
322
|
+
const all = (v: View) => tasksOf(v.data, v.projectId);
|
|
323
|
+
const visible = (v: View) => all(v).filter((t) => v.showDone || !t.done);
|
|
324
|
+
|
|
325
|
+
export class ProjectPage extends Component(tpl, {
|
|
326
|
+
props: { projectId: "" },
|
|
327
|
+
state: { data: { projects: [], tasks: [], nextId: 0 } as ProjectsState, showDone: true },
|
|
328
|
+
derive: {
|
|
329
|
+
found: (v) => project(v) !== undefined,
|
|
330
|
+
missing: (v) => project(v) === undefined,
|
|
331
|
+
name: (v) => project(v)?.name ?? "",
|
|
332
|
+
tasks: (v) => [...visible(v)].sort((a, b) => Number(a.done) - Number(b.done)),
|
|
333
|
+
doneCount: (v) => all(v).filter((t) => t.done).length,
|
|
334
|
+
totalCount: (v) => all(v).length,
|
|
335
|
+
hiddenCount: (v) => all(v).length - visible(v).length,
|
|
336
|
+
someHidden: (v) => all(v).length > visible(v).length,
|
|
337
|
+
},
|
|
338
|
+
}) {
|
|
339
|
+
override connected(): void {
|
|
340
|
+
this.bind(projects, (data) => this.set({ data }));
|
|
341
|
+
this.bind(preferences, (p) => this.set({ showDone: p.showDone }));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
onAdd(e: Event): void {
|
|
345
|
+
addTask(this.props.projectId, (e as EmitEvent<TextEntry, "entry-submit">).detail.value);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
onToggle(_e: Event, hit: EventHit<Task, "id">): void {
|
|
349
|
+
toggleTask(hit.key);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
override render(
|
|
353
|
+
view: { tasks: readonly Task[]; doneCount: number; totalCount: number },
|
|
354
|
+
changed: ReadonlySet<"tasks" | "doneCount" | "totalCount">,
|
|
355
|
+
): void {
|
|
356
|
+
if (changed.has("tasks")) this.lists.tasks.items = view.tasks;
|
|
357
|
+
if (changed.has("doneCount")) this.refs.meter.done = view.doneCount;
|
|
358
|
+
if (changed.has("totalCount")) this.refs.meter.total = view.totalCount;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
define("project-page", ProjectPage);
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
`preferences` is the app-wide store (`createStore<Preferences>({ userName: "", showDone: true })` in `src/shared/stores/preferences.ts`), written by the settings page and read by the shell and this page. `text-entry` and `progress-meter` come from `src/shared/`; `task-row` is this feature's own item component. The page's template uses their tags, and the generated `project-page.html.ts` imports all three.
|
|
365
|
+
|
|
366
|
+
The configuration (the browser project and the Playwright config follow `nativele-testing`, with this project's ports):
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
// vite.config.ts
|
|
370
|
+
import { playwright } from "@vitest/browser-playwright";
|
|
371
|
+
import { defineConfig } from "vitest/config";
|
|
372
|
+
import { tsDom } from "@nativele/ts-dom/vite";
|
|
373
|
+
|
|
374
|
+
const chrome = process.env.CHROME_BIN ?? "/usr/bin/google-chrome";
|
|
375
|
+
|
|
376
|
+
export default defineConfig({
|
|
377
|
+
plugins: [tsDom({ dirs: ["src"] })],
|
|
378
|
+
server: { port: 5481, strictPort: true },
|
|
379
|
+
preview: { port: 4481, strictPort: true },
|
|
380
|
+
test: {
|
|
381
|
+
projects: [
|
|
382
|
+
{
|
|
383
|
+
extends: true,
|
|
384
|
+
test: { name: "unit", include: ["src/**/*.test.ts"], exclude: ["src/**/*.browser.test.ts"], environment: "happy-dom" },
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
extends: true,
|
|
388
|
+
test: {
|
|
389
|
+
name: "browser",
|
|
390
|
+
include: ["src/**/*.browser.test.ts"],
|
|
391
|
+
api: { port: 63481, strictPort: true },
|
|
392
|
+
browser: {
|
|
393
|
+
enabled: true,
|
|
394
|
+
headless: true,
|
|
395
|
+
provider: playwright({ launchOptions: { executablePath: chrome, args: ["--no-sandbox", "--no-zygote", "--disable-gpu", "--disable-dev-shm-usage"] } }),
|
|
396
|
+
instances: [{ browser: "chromium" }],
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
// playwright.config.ts: the build comes from npm run build, which verify runs first
|
|
407
|
+
import { defineConfig } from "@playwright/test";
|
|
408
|
+
|
|
409
|
+
const chrome = process.env.CHROME_BIN ?? "/usr/bin/google-chrome";
|
|
410
|
+
|
|
411
|
+
export default defineConfig({
|
|
412
|
+
testDir: "e2e",
|
|
413
|
+
use: {
|
|
414
|
+
baseURL: "http://localhost:4481",
|
|
415
|
+
launchOptions: { executablePath: chrome, args: ["--no-sandbox", "--no-zygote", "--disable-gpu", "--disable-dev-shm-usage"] },
|
|
416
|
+
},
|
|
417
|
+
webServer: { command: "npx vite preview", url: "http://localhost:4481" },
|
|
418
|
+
});
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
One test of each kind:
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
// src/features/projects/project-page.test.ts (happy-dom)
|
|
425
|
+
beforeEach(() => {
|
|
426
|
+
projects.set(seedProjects());
|
|
427
|
+
preferences.set(defaultPreferences);
|
|
428
|
+
});
|
|
429
|
+
afterEach(() => document.body.replaceChildren());
|
|
430
|
+
|
|
431
|
+
test("leaving the page ends its subscriptions: a removed page no longer renders on store changes", () => {
|
|
432
|
+
const el = document.createElement("project-page");
|
|
433
|
+
el.projectId = "p1";
|
|
434
|
+
document.body.append(el);
|
|
435
|
+
const render = vi.spyOn(ProjectPage.prototype, "render");
|
|
436
|
+
preferences.set({ ...preferences.get(), showDone: false });
|
|
437
|
+
expect(render).toHaveBeenCalledTimes(1);
|
|
438
|
+
render.mockClear();
|
|
439
|
+
el.remove();
|
|
440
|
+
projects.set(seedProjects());
|
|
441
|
+
preferences.set({ ...preferences.get(), showDone: true });
|
|
442
|
+
expect(render).not.toHaveBeenCalled();
|
|
443
|
+
});
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
// src/app/router.browser.test.ts (Chrome)
|
|
448
|
+
const start = location.href;
|
|
449
|
+
const outlet = document.createElement("main");
|
|
450
|
+
document.body.append(outlet);
|
|
451
|
+
const ready = startRouter(outlet);
|
|
452
|
+
|
|
453
|
+
afterAll(() => navigation.navigate(start, { history: "replace" }).finished);
|
|
454
|
+
|
|
455
|
+
test("the Navigation API carries same-document navigations to the router", async () => {
|
|
456
|
+
await ready;
|
|
457
|
+
await navigation.navigate("/settings").finished;
|
|
458
|
+
expect(outlet.firstElementChild?.localName).toBe("settings-page");
|
|
459
|
+
});
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
// e2e/app.spec.ts (Playwright Test, built app)
|
|
464
|
+
test("each page module is loaded only when its route is first shown", async ({ page }) => {
|
|
465
|
+
const scripts: string[] = [];
|
|
466
|
+
page.on("request", (r) => {
|
|
467
|
+
if (r.resourceType() === "script") scripts.push(new URL(r.url()).pathname.replace(/-[\w-]{8}\.js$/, ".js"));
|
|
468
|
+
});
|
|
469
|
+
await page.goto("/settings");
|
|
470
|
+
await expect(page.locator("#outlet h1")).toHaveText("Settings");
|
|
471
|
+
expect(scripts.sort()).toEqual(["/assets/index.js", "/assets/settings-page.js"]);
|
|
472
|
+
});
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
## Known limitations
|
|
476
|
+
|
|
477
|
+
1. **Scan one folder.** Several folders work too (`dirs: ["src/shared", "src/features"]`): a template imports a child defined in any scanned folder, and a tag defined in two of them is `duplicate-define`. But every folder then needs to be named in `tsDom({ dirs })`, `ts-dom check`, `ts-dom doctor` and the tsconfig `include` alike, and a folder left out of one of them is silently not generated or not checked there. One folder, `src`, avoids that. A component outside every scanned folder is not imported automatically: import its module where it is used.
|
|
478
|
+
2. **Browser support.** The router needs `URLPattern`: without it the app stops at start-up with `URLPattern is not defined`. Without the Navigation API, `startRouter` attaches nothing, every link loads a new document, and the router renders the route at start-up (verified in Chrome with `navigation` removed). Only Chrome 148 was verified; check your target browsers, or use a History API router (step 5).
|
|
479
|
+
3. **Keep the abort check.** Without `if (signal?.aborted) return;` after the import, a page module that loads slowly mounts over the page of a later navigation, and the address bar and the page disagree. The example's end-to-end test delays a chunk to prove the check, for both routers.
|
|
480
|
+
4. **Every app path must return `index.html`.** Deep links and reloads (and every link, without the Navigation API) ask the server for the page's path. `vite dev` and `vite preview` answer with `index.html`; a production server must be set up the same way (not verified here).
|
|
481
|
+
5. **A page is a new element on every navigation.** Its `state` starts over, also on back and forward, and also between `/projects/p1` and `/projects/p2`. Keep what must survive in a store.
|
|
482
|
+
6. **Feature stores live as long as the document.** A store created in a lazily loaded module is created on the first visit to the feature and kept until a reload; reset it in tests with `store.set(seed())` in `beforeEach`.
|
|
483
|
+
7. **happy-dom has no Navigation API and no `moveBefore`.** A test that uses `navigation` fails there with `ReferenceError: navigation is not defined`, and a reordered list item runs `disconnected()` and loses focus where Chrome keeps both. Put them in `*.browser.test.ts`; the route table can still be tested in happy-dom through `show()`.
|
|
484
|
+
8. Not verified: routers other than the two in step 5, nested routes and layouts, route guards, query strings and hashes as props (the `URLPattern` here matches the path only), scroll restoration and focus after a navigation, server-side rendering, browsers other than Chrome, production servers, and oxlint with tests next to the components (the `ts-dom/unused-markup` rule counts only the component's own class, see `nativele-testing`).
|
|
485
|
+
|
|
486
|
+
## Verify
|
|
487
|
+
|
|
488
|
+
Run in the project:
|
|
489
|
+
|
|
490
|
+
```sh
|
|
491
|
+
npm run build # expect: ts-dom check: no issues, then ✓ built, with one chunk per page module
|
|
492
|
+
npx ts-dom doctor src # expect: ts-dom doctor: the set-up looks right (info lines are suggestions)
|
|
493
|
+
npm test # expect: every src/**/*.test.ts passes in happy-dom
|
|
494
|
+
npm run test:browser # expect: every src/**/*.browser.test.ts passes in headless Chrome
|
|
495
|
+
npm run test:e2e # expect: every e2e spec passes against vite preview on the built app
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
The example project's `npm run verify` runs exactly these, from a clean checkout with nothing generated; its output (the `info` lines left out):
|
|
499
|
+
|
|
500
|
+
```
|
|
501
|
+
ts-dom check: no issues
|
|
502
|
+
dist/index.html 0.49 kB
|
|
503
|
+
dist/assets/settings-page-CdmdUBHw.js 0.72 kB
|
|
504
|
+
dist/assets/projects-page-C9cYN-pF.js 1.20 kB
|
|
505
|
+
dist/assets/text-entry-C1mgKfH1.js 1.54 kB
|
|
506
|
+
dist/assets/project-page-CqUTMG_P.js 1.98 kB
|
|
507
|
+
dist/assets/index-B8nptlG1.js 21.00 kB
|
|
508
|
+
✓ built in 100ms
|
|
509
|
+
ts-dom doctor: the set-up looks right
|
|
510
|
+
Test Files 7 passed (7)
|
|
511
|
+
Tests 19 passed (19)
|
|
512
|
+
Test Files 2 passed (2)
|
|
513
|
+
Tests 4 passed (4)
|
|
514
|
+
8 passed (3.9s)
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
The end-to-end tests cover: a deep link, links without a new document and a store keeping data across pages, the app-wide store reaching the shell and another feature, page chunks loaded on demand, a superseded navigation, a page chunk that fails to load, an unknown path, and a browser without the Navigation API.
|
|
518
|
+
|
|
519
|
+
## Troubleshooting
|
|
520
|
+
|
|
521
|
+
| Symptom | Cause | Fix |
|
|
522
|
+
|---|---|---|
|
|
523
|
+
| A shared component's tag stays empty on a page, with no error from `check`, `doctor`, `lint` or the build | the component is defined outside every scanned folder, so the generated module does not import it | scan the folder that holds it (`tsDom({ dirs: ["src"] })`, `ts-dom check src`), or import the component's module yourself |
|
|
524
|
+
| `TS2307: Cannot find module './x.js'` in `src/nv/ts-dom-tags.ts` after moving components | a `ts-dom-tags.ts` left in the folder that was scanned before | delete it, and the old folder once it is empty; only `src/ts-dom-tags.ts` is generated now |
|
|
525
|
+
| `[duplicate-define] the tag <x> is defined more than once` after merging folders into one scan | two features define the same tag | rename one; tags share one registry |
|
|
526
|
+
| The page of an earlier link appears after a later one, and the address bar shows the later URL | the abort check after the import is missing | `if (signal?.aborted) return;` after `await` in `show()` |
|
|
527
|
+
| `URLPattern is not defined` at start-up | the browser has no `URLPattern` | use a History API router (step 5) for those browsers |
|
|
528
|
+
| `ReferenceError: navigation is not defined` in a Vitest test | the test runs in happy-dom | rename it `*.browser.test.ts`, or test `show()` instead |
|
|
529
|
+
| 404 on a reload or a deep link in production | the server does not answer app paths with `index.html` | configure the server's fallback to `index.html` |
|
|
530
|
+
| `TS2345: ... is not assignable to parameter of type 'keyof HTMLElementTagNameMap'` in the route table | the tag is misspelled | use the tag the page module passes to `define()` |
|
|
531
|
+
| `TS2345: ... has no properties in common with type 'Partial<...>'` in the route table | the props function names a prop the page does not declare | use the page's prop name |
|
|
532
|
+
|
|
533
|
+
## See also
|
|
534
|
+
|
|
535
|
+
- Why the layout, the routing contract and the state levels: https://github.com/Pixmi/nativele/blob/main/docs/architecture.md
|
|
536
|
+
- `bind`, `createStore`, props and events: https://github.com/Pixmi/nativele/blob/main/docs/components.md
|
|
537
|
+
- Lists of item components (`data-key`, `data-prop`) and moving items: https://github.com/Pixmi/nativele/blob/main/docs/lists.md
|
|
538
|
+
- What `ts-dom check`, `doctor` and `gen` check and write: https://github.com/Pixmi/nativele/blob/main/docs/tooling.md#checks
|
|
539
|
+
- Setting up Vite: `npx ts-dom skills nativele-vite`
|
|
540
|
+
- State libraries through `bind`: `npx ts-dom skills nativele-state-management`
|
|
541
|
+
- Writing the tests: `npx ts-dom skills nativele-testing`
|
|
@@ -16,7 +16,7 @@ metadata:
|
|
|
16
16
|
|
|
17
17
|
- The app is built with a UI framework and wants to use components written with Nativele (`@nativele/ts-dom`), and no Skill exists for that framework yet. React, Vue, Svelte and Angular have their own (`nativele-react`, `nativele-vue`, `nativele-svelte`, `nativele-angular`); `npx ts-dom skills` lists every Skill.
|
|
18
18
|
- It is a procedure for finding out how the framework treats custom elements and connecting it correctly. It does not cover setting the framework itself up: start from the app as it is, with its own type checker and build.
|
|
19
|
-
- A framework with its own file format type-checks it with its own tool (Vue: `vue-tsc`). `ts-dom check` runs `vue-tsc` instead of `tsc` when the project's `package.json` lists it; for other formats see Troubleshooting. A framework that uses TSX (Preact, Solid) is checked by plain `tsc`, and `ts-dom check` reports its errors too.
|
|
19
|
+
- A framework with its own file format type-checks it with its own tool (Vue: `vue-tsc`). `ts-dom check` runs `vue-tsc` instead of `tsc` when the project's `package.json` lists it and it resolves from the project folder; for other formats see Troubleshooting. A framework that uses TSX (Preact, Solid) is checked by plain `tsc`, and `ts-dom check` reports its errors too.
|
|
20
20
|
- Not for writing the Nativele components themselves: use `nativele-core` (`npx ts-dom skills add nativele-core`).
|
|
21
21
|
|
|
22
22
|
Nativele components are native custom elements. The framework renders their tags like any other tag; nothing wraps them. Every rule below follows from that.
|
|
@@ -56,8 +56,8 @@ Verified on 2026-11-05 in Google Chrome 148, starting from `create-vite` 9.3.4 w
|
|
|
56
56
|
|
|
57
57
|
Many frameworks decide between property and attribute by checking whether the element already has the property, which is only true after `define()` has run.
|
|
58
58
|
|
|
59
|
-
5. Give the framework's template type checker the Nativele tags. `ts-dom gen` writes `ts-dom-tags.ts`, which adds every exported component to `HTMLElementTagNameMap`;
|
|
60
|
-
6. The generated files are not committed, so on a clean checkout a build script that runs the type checker before the bundler (the `preact-ts` template's `"build": "tsc -b && vite build"`) fails with `TS2307: Cannot find module './x.html.js'`. Put `ts-dom gen src/nv &&` in front: `"build": "ts-dom gen src/nv && tsc -b && vite build"`. The dev server needs no change: the Vite plugin generates the files when it starts.
|
|
59
|
+
5. Give the framework's template type checker the Nativele tags. `ts-dom gen` writes `ts-dom-tags.ts`, which adds every exported component to `HTMLElementTagNameMap`; the element type members a declaration reads (`props` and `"~emits"`) are in https://github.com/Pixmi/nativele/blob/main/docs/components.md#element-types-for-framework-declarations. Build the framework's tag declaration from these, so new components need no edit; "In Preact (verified)" below has the one for Preact's JSX, `nativele-react` the one for React and `nativele-vue` the Vue one. Check how the framework names an event listener in its types: Preact and React type `item-select` as the prop `onitem-select`, Vue types `@item-select` as `onItemSelect`. If the framework has no typed templates, skip this step.
|
|
60
|
+
6. The generated files are not committed, so on a clean checkout a build script that runs the type checker before the bundler (the `preact-ts` template's `"build": "tsc -b && vite build"`) fails with `TS2307: Cannot find module './x.html.js'`. Put `ts-dom gen src/nv &&` in front: `"build": "ts-dom gen src/nv && tsc -b && vite build"`. Do the same in front of whatever type check the framework's template runs (`vue-tsc -b`, `svelte-check`). Only a project whose type check is plain `tsc` replaces it with `ts-dom check`, which generates and type-checks: `"build": "ts-dom check src/nv && vite build"`, as `nativele-vite` sets it up. The dev server needs no change: the Vite plugin generates the files when it starts.
|
|
61
61
|
|
|
62
62
|
## Example
|
|
63
63
|
|
|
@@ -138,9 +138,7 @@ A template ref is typed from the same map: `useRef<HTMLElementTagNameMap["nv-inf
|
|
|
138
138
|
|
|
139
139
|
1. **Define before render.** Import the component modules before the framework mounts (step 4). A framework that sees an undefined element may write an attribute instead of a property: in Preact, `item={item}` becomes the attribute `item="[object Object]"`, the component gets `null` after `define()`, and the attribute stays even after a later new object is set as a property. A property set before `define()` is kept: Nativele takes it over when the element is upgraded. So when a module really is loaded later, set object props through the framework's property syntax, or, where it has none (Preact), from a ref: `ref={(el) => { if (el) el.item = item; }}`, and leave `item={item}` out of that tag, or Preact also writes the attribute.
|
|
140
140
|
2. **Props.** A prop whose default is a string, number or boolean can also come from an attribute, with camelCase names in kebab-case (`maxLength` from `max-length`, a boolean `true` when the attribute is present). Arrays and objects can only be set as properties. Pass a new object when data changes: props are compared by reference.
|
|
141
|
-
3. **Light DOM slots and framework-managed children do not mix
|
|
142
|
-
- `shadow: true` on the component, so the browser assigns slots natively and the children can change freely;
|
|
143
|
-
- one stable element per slot that the framework never removes, with the changing content inside it (`<div slot="title">...</div>`, `<div>...</div>` for the default slot). A stable but empty wrapper still fills its slot, so the slot's fallback content does not show; when the fallback matters, use `shadow: true`.
|
|
141
|
+
3. **Light DOM slots and framework-managed children do not mix** (https://github.com/Pixmi/nativele/blob/main/docs/components.md#slots-and-children-managed-by-a-framework): a framework that inserts, removes or reorders the children of a Light DOM host with slots can fail or leave nodes in the wrong slot. In Preact, toggling or reordering such children throws `NotFoundError: Failed to execute 'moveBefore' on 'Element'` (or `'insertBefore' on 'Node'`) and the slot loses its children. Use `shadow: true`, or one stable element per slot that the framework never removes, with the changing content inside it.
|
|
144
142
|
4. **Events do not bubble.** `emit` dispatches a `CustomEvent` on the component that does not bubble, so the listener must be on that element, not on an ancestor. The event data is `event.detail`.
|
|
145
143
|
5. **Lifecycle.** `connected()` runs each time the element is inserted and `disconnected()` each time it is removed, and `bind` subscriptions end on removal and start again on insertion. A framework that moves an element (reordering a keyed list) with `insertBefore` runs both; one that moves it with `moveBefore` keeps it connected, so neither runs and its subscriptions continue. Preact 11 uses `moveBefore` where the browser has it (Chrome does) and `insertBefore` elsewhere. Either way the moved element keeps its props and the children in its Light DOM slots. So subscribe in `connected()`, and do not rely on a move running it.
|
|
146
144
|
6. **The framework's templates are checked by the framework's type checker**, with the declaration from step 5, not by the Nativele rules. How much it checks depends on the framework: with the Preact declaration, `tsc` reports a wrong prop type, a wrong object shape, a wrong use of `e.detail`, an unknown prop name, an event prop in the wrong case (`onItemSelect`) and an unknown tag, but not a hyphenated attribute name (`selected-item`), which TypeScript never checks in JSX; `vue-tsc` reports a wrong prop type or event detail, but not an unknown prop name.
|
|
@@ -162,18 +162,14 @@ define("shadow-card", ShadowCard);
|
|
|
162
162
|
|
|
163
163
|
1. **Define before render.** Import every Nativele module before React first renders its tag (step 6). Otherwise props become attributes and object props are lost.
|
|
164
164
|
2. **Event prop names.** Only `on` plus the exact event name works (`onitem-select`); `onItemSelect` and `onItem-select` never fire. Nativele events do not bubble by default, so put the handler on the element that emits. A ref with `addEventListener` in `useEffect` also works (remove it in the cleanup).
|
|
165
|
-
3. **Light DOM slots and React children do not mix
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
</light-card>
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
A stable but empty wrapper still fills its slot, so the slot's fallback content does not show; when the fallback matters, use `shadow: true`.
|
|
165
|
+
3. **Light DOM slots and React children do not mix** (https://github.com/Pixmi/nativele/blob/main/docs/components.md#slots-and-children-managed-by-a-framework). When React inserts, removes or reorders the children of a Light DOM host with slots, it fails with `insertBefore`/`removeChild` errors that unmount the root, or leaves nodes outside the slot. Changing text in place and appending at the end happen to work, but do not rely on that. Use `shadow: true`, or one stable React element per slot and let React change only what is inside it:
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
<light-card>
|
|
169
|
+
<div slot="title">{showTitle && <span>{title}</span>}</div>
|
|
170
|
+
<div>{rows.map((r) => <p key={r.id}>{r.text}</p>)}</div>
|
|
171
|
+
</light-card>
|
|
172
|
+
```
|
|
177
173
|
4. **New objects for changed props.** A Nativele component compares props by reference; pass a new object when the data changes (React state updates usually do).
|
|
178
174
|
5. **TSX types need the declaration** in step 7; without it, every Nativele tag is `TS2339: Property 'info-card' does not exist on type 'JSX.IntrinsicElements'`. Check it with a `.tsx` that has a wrong prop under `// @ts-expect-error`, because `skipLibCheck` can hide mistakes inside the declaration itself. Keep these cases in a `.tsx` file of their own that the app never imports (the example uses `src/types-check.tsx`).
|
|
179
175
|
6. Not verified: server-side rendering, form controls inside Nativele components submitting with a React form, and React versions other than 19.3.
|
|
@@ -196,18 +196,14 @@ Used from Svelte:
|
|
|
196
196
|
|
|
197
197
|
1. **Define before mount.** Import the component modules before `mount` (step 6). Svelte 5 decides per value: when the element is defined, it sets a property the element has; when it is not defined yet (for example a module loaded later with `import()`), it sets objects and arrays as properties and writes every other value as an attribute string. Nativele reads both when the element is upgraded, which was verified for string, number and boolean props with one-word names (`count={0}` becomes `count="0"`, `false` becomes `open="false"`, both arrive with the right type). Other cases were not verified, so define first.
|
|
198
198
|
2. **Event attribute names.** Only `on` plus the exact event name works (`onitem-select`). `onItemSelect` listens to an event named `ItemSelect` and never fires; with the declaration it is also a `svelte-check` error. The legacy directive `on:item-select` fires too, but only use the event attributes: in a runes component `on:` is deprecated (compiler warning `event_directive_deprecated`), a component cannot mix it with event attributes (compile error `mixed_event_handler_syntaxes`), and the declaration in step 7 does not type it. Nativele events do not bubble, so put the handler on the element that emits.
|
|
199
|
-
3. **Light DOM slots and Svelte-managed children do not mix
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
</light-card>
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
A stable but empty wrapper still fills its slot, so the slot's fallback content does not show; when the fallback matters, use `shadow: true`.
|
|
199
|
+
3. **Light DOM slots and Svelte-managed children do not mix** (https://github.com/Pixmi/nativele/blob/main/docs/components.md#slots-and-children-managed-by-a-framework). Nativele moves the comment nodes Svelte uses as anchors into the slot positions together with the children. Svelte does not throw, but it goes wrong silently: an `{#if}` that adds `<span slot="title">` back inserts it next to its anchor, which now sits in the default slot, so the title slot shows its fallback; and `assignedNodes()` stops matching what is on the page. Keyed `{#each}` reordering happened to look right, but do not rely on it. Use `shadow: true`, or one stable element per slot and let Svelte change only what is inside it:
|
|
200
|
+
|
|
201
|
+
```svelte
|
|
202
|
+
<light-card>
|
|
203
|
+
<div slot="title">{#if showTitle}<span>Title</span>{/if}</div>
|
|
204
|
+
<div>{#each rows as r (r)}<p>{r}</p>{/each}</div>
|
|
205
|
+
</light-card>
|
|
206
|
+
```
|
|
211
207
|
4. **New objects for changed props.** A Nativele component compares props by reference. Changing a `$state` object in place (`item.name = "kiwi"`) does not re-render it; assign a new object (`item = { ...item, name: "kiwi" }`).
|
|
212
208
|
5. **Write closing tags.** `<info-card ... />` works, but the Svelte compiler warns `element_invalid_self_closing_tag` (shown in the dev server's terminal); write `<info-card ...></info-card>`.
|
|
213
209
|
6. **Template types need the declaration** in step 7. Without it, Svelte accepts any attribute on a hyphenated tag, and an inline handler's parameter is `any` (`Parameter 'e' implicitly has an 'any' type` under `strict`). With it, `svelte-check` reports a wrong prop type, an unknown prop name, a wrong `e.detail` field and a camelCase handler name. `<!-- @ts-expect-error -->` in markup does not silence an error, so keep the expected-error cases in a `.ts` file (step 8). To type an element reference, use the tag map: `let card: HTMLElementTagNameMap["info-card"] | undefined = $state()` with `bind:this={card}`.
|