@nativele/ts-dom 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +10 -0
- package/README.zh-TW.md +10 -0
- package/dist/{a11y-wWL9rie7.mjs → a11y-CV3nBgBn.mjs} +1 -1
- package/dist/cli.mjs +38 -16
- package/dist/index.mjs +3 -3
- package/dist/{lint-5vo_ifIc.mjs → lint-Bwytj7L8.mjs} +2 -2
- package/dist/oxlint-plugin.mjs +2 -2
- package/dist/vite-plugin.mjs +1 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/skills/README.md +36 -12
- package/skills/nativele-angular/SKILL.md +339 -0
- package/skills/nativele-core/SKILL.md +92 -7
- package/skills/nativele-integration/SKILL.md +195 -0
- package/skills/nativele-react/SKILL.md +30 -14
- package/skills/nativele-state-management/SKILL.md +319 -0
- package/skills/nativele-svelte/SKILL.md +259 -0
- package/skills/nativele-tailwind/SKILL.md +220 -0
- package/skills/nativele-tanstack-query/SKILL.md +302 -0
- package/skills/nativele-testing/SKILL.md +364 -0
- package/skills/nativele-vite/SKILL.md +197 -0
- package/skills/nativele-vue/SKILL.md +279 -0
- package/skills/nativele-zod/SKILL.md +345 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nativele-vue
|
|
3
|
+
description: "Use Nativele web components inside a Vue 3 app: telling the template compiler which tags are custom elements, passing props, listening to events, replacing v-model, children and slots, lifecycle with v-for, v-if and KeepAlive, and SFC template types. Use when a project depends on vue and @nativele/ts-dom."
|
|
4
|
+
metadata:
|
|
5
|
+
tier: official
|
|
6
|
+
nativele: ">=0.5.1 <0.6.0"
|
|
7
|
+
verified: "2026-11-05"
|
|
8
|
+
tools: "vue@3.5.44 vite@8.3.6 @vitejs/plugin-vue@6.0.10 vue-tsc@3.3.14 typescript@6.0.3 @vue/tsconfig@0.9.1"
|
|
9
|
+
author: "Nativele maintainers"
|
|
10
|
+
detects: "vue"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Nativele + Vue
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
- The project renders with Vue 3 single-file components and wants to use components written with Nativele (`@nativele/ts-dom`), for example a design-system layer or widgets shared with non-Vue pages.
|
|
18
|
+
- Signals: `vue` and `@nativele/ts-dom` in `package.json`, `.vue` templates that render Nativele tags (`<nv-info-card>`).
|
|
19
|
+
- The starting point is a working Vue app, such as the one `npm create vite@latest -- --template vue-ts` makes. This Skill does not set Vue or Vite up.
|
|
20
|
+
- Not for Vue 2, and not for Vue's own `defineCustomElement` (that turns Vue components into custom elements; this Skill goes the other way). Not for writing the Nativele components themselves: use `nativele-core` (`npx ts-dom skills add nativele-core`).
|
|
21
|
+
|
|
22
|
+
Nativele components are native custom elements. Vue renders them like any other element; nothing wraps them.
|
|
23
|
+
|
|
24
|
+
## Versions
|
|
25
|
+
|
|
26
|
+
| Tool | Verified version |
|
|
27
|
+
|---|---|
|
|
28
|
+
| @nativele/ts-dom | 0.5.1 |
|
|
29
|
+
| vue | 3.5.44 |
|
|
30
|
+
| vite | 8.3.6 |
|
|
31
|
+
| @vitejs/plugin-vue | 6.0.10 |
|
|
32
|
+
| vue-tsc | 3.3.14 |
|
|
33
|
+
| typescript | 6.0.3 |
|
|
34
|
+
| @vue/tsconfig | 0.9.1 |
|
|
35
|
+
|
|
36
|
+
Verified on 2026-11-05 in Google Chrome 148, starting from `create-vite` 9.3.4 with the `vue-ts` template, with the example project in the Nativele repository (`skills/nativele-vue/example`), which CI runs again when the example or the library changes and once a month. With a different major version of Vue, treat every step as unverified.
|
|
37
|
+
|
|
38
|
+
## Set-up
|
|
39
|
+
|
|
40
|
+
1. Keep the Nativele components in their own folder, for example `src/nv/`, each as `x.html` plus `x.ts`, written with the core Skill (`npx ts-dom skills add nativele-core`). Give every Nativele tag one prefix that no Vue component uses, for example `nv-` (`define("nv-info-card", InfoCard)`); step 4 depends on it.
|
|
41
|
+
2. Install: `npm i @nativele/ts-dom`.
|
|
42
|
+
3. Keep `"strict": true` for that folder. In the `vue-ts` template, `tsconfig.app.json` extends `@vue/tsconfig/tsconfig.dom.json`, which already sets it; if the project turned it off, turn it on there. Nativele's type checks depend on it.
|
|
43
|
+
4. Add the Nativele Vite plugin, and tell Vue's template compiler that the tags with the prefix are custom elements:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// vite.config.ts
|
|
47
|
+
import vue from "@vitejs/plugin-vue";
|
|
48
|
+
import { defineConfig } from "vite";
|
|
49
|
+
import { tsDom } from "@nativele/ts-dom/vite";
|
|
50
|
+
|
|
51
|
+
export default defineConfig({
|
|
52
|
+
plugins: [tsDom({ dirs: ["src/nv"] }), vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith("nv-") } } })],
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Match the prefix only, never every hyphenated tag (limitation 1). Pass the same folder to every `ts-dom` command (`npx ts-dom check src/nv`), so there is one `ts-dom-tags.ts`, in `src/nv`.
|
|
57
|
+
|
|
58
|
+
5. Add `*.html.ts` and `ts-dom-tags.ts` to `.gitignore`: `ts-dom` writes them, and they are not committed.
|
|
59
|
+
6. Import every Nativele component module in `src/main.ts`, before `createApp(...).mount(...)`:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// src/main.ts
|
|
63
|
+
import "./nv/info-card.js";
|
|
64
|
+
import "./nv/text-field.js";
|
|
65
|
+
import { createApp } from "vue";
|
|
66
|
+
import App from "./App.vue";
|
|
67
|
+
|
|
68
|
+
createApp(App).mount("#app");
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Vue sets a binding as a property only when the element already has that property, which is true only after `define()` has run (limitation 2).
|
|
72
|
+
|
|
73
|
+
7. Add the template type declaration below as `src/nativele-vue.d.ts` (the template's `tsconfig.app.json` already includes `src/**/*.ts`). It reads the tag map that `ts-dom gen` writes (`ts-dom-tags.ts`), so every exported component gets typed props and events in SFC templates, and new components need no edit:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// src/nativele-vue.d.ts
|
|
77
|
+
import type { HTMLAttributes } from "vue";
|
|
78
|
+
|
|
79
|
+
type NativeleTag = Extract<keyof HTMLElementTagNameMap, `${string}-${string}`>;
|
|
80
|
+
type Camelize<S extends string> = S extends `${infer A}-${infer B}` ? `${A}${Capitalize<Camelize<B>>}` : S;
|
|
81
|
+
type NativeleProps<E> = E extends { readonly props: infer P } ? { [K in keyof P & keyof E]?: E[K] } : unknown;
|
|
82
|
+
type NativeleEvents<E> = E extends { readonly "~emits"?: infer M }
|
|
83
|
+
? { [K in keyof NonNullable<M> & string as `on${Capitalize<Camelize<K>>}`]?: (event: CustomEvent<NonNullable<M>[K]>) => void }
|
|
84
|
+
: unknown;
|
|
85
|
+
type NativeleComponents = {
|
|
86
|
+
[K in NativeleTag]: new () => {
|
|
87
|
+
$props: HTMLAttributes & NativeleProps<HTMLElementTagNameMap[K]> & NativeleEvents<HTMLElementTagNameMap[K]>;
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
declare module "vue" {
|
|
92
|
+
interface GlobalComponents extends NativeleComponents {}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The component class must be exported and passed to `define()` in a folder that `ts-dom gen` scans, so it appears in `ts-dom-tags.ts`. A template ref is typed from the same map: `useTemplateRef<HTMLElementTagNameMap["nv-info-card"]>("card")`.
|
|
97
|
+
|
|
98
|
+
The generated files are not committed, so on a clean checkout the type check in `npm run build` runs before anything has written them and fails with `TS2307: Cannot find module './x.html.js'`. Generate them first: `"build": "ts-dom gen src/nv && vue-tsc -b && vite build"` (the template's script with `ts-dom gen src/nv &&` in front).
|
|
99
|
+
|
|
100
|
+
## Example
|
|
101
|
+
|
|
102
|
+
A Nativele component with props and an event:
|
|
103
|
+
|
|
104
|
+
```html
|
|
105
|
+
<!-- src/nv/info-card.html -->
|
|
106
|
+
<span>{{ label }}</span>
|
|
107
|
+
<b>{{ count }}</b>
|
|
108
|
+
<i data-ref="itemName"></i>
|
|
109
|
+
<button type="button" data-on-click="pick">pick</button>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// src/nv/info-card.ts
|
|
114
|
+
import { Component, define, event } from "@nativele/ts-dom";
|
|
115
|
+
import tpl from "./info-card.html.js";
|
|
116
|
+
|
|
117
|
+
export interface Item {
|
|
118
|
+
id: number;
|
|
119
|
+
name: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class InfoCard extends Component(tpl, {
|
|
123
|
+
props: { label: "", count: 0, item: null as Item | null },
|
|
124
|
+
emits: { "item-select": event<{ id: number }>() },
|
|
125
|
+
}) {
|
|
126
|
+
pick(): void {
|
|
127
|
+
const item = this.props.item;
|
|
128
|
+
if (item) this.emit("item-select", { id: item.id });
|
|
129
|
+
}
|
|
130
|
+
override render(view: { item: Item | null }): void {
|
|
131
|
+
this.refs.itemName.textContent = view.item ? view.item.name : "-";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
define("nv-info-card", InfoCard);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A field that takes the place of `v-model`: a `value` prop down, a `value-change` event up.
|
|
138
|
+
|
|
139
|
+
```html
|
|
140
|
+
<!-- src/nv/text-field.html -->
|
|
141
|
+
<input data-ref="input" data-on-input="onInput" />
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
// src/nv/text-field.ts
|
|
146
|
+
import { Component, define, event } from "@nativele/ts-dom";
|
|
147
|
+
import tpl from "./text-field.html.js";
|
|
148
|
+
|
|
149
|
+
export class TextField extends Component(tpl, {
|
|
150
|
+
props: { value: "" },
|
|
151
|
+
emits: { "value-change": event<{ value: string }>() },
|
|
152
|
+
}) {
|
|
153
|
+
onInput(): void {
|
|
154
|
+
this.emit("value-change", { value: this.refs.input.value });
|
|
155
|
+
}
|
|
156
|
+
override render(view: { value: string }): void {
|
|
157
|
+
if (this.refs.input.value !== view.value) this.refs.input.value = view.value;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
define("nv-text-field", TextField);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Used from a Vue component:
|
|
164
|
+
|
|
165
|
+
```vue
|
|
166
|
+
<script setup lang="ts">
|
|
167
|
+
import { ref } from "vue";
|
|
168
|
+
import type { Item } from "./nv/info-card.js";
|
|
169
|
+
|
|
170
|
+
const count = ref(0);
|
|
171
|
+
const item = ref<Item>({ id: 1, name: "apple" });
|
|
172
|
+
const picked = ref<number[]>([]);
|
|
173
|
+
const text = ref("");
|
|
174
|
+
</script>
|
|
175
|
+
|
|
176
|
+
<template>
|
|
177
|
+
<nv-info-card label="Fruit" :count="count" :item="item" @item-select="(e) => picked.push(e.detail.id)" />
|
|
178
|
+
<nv-text-field :value="text" @value-change="(e) => (text = e.detail.value)" />
|
|
179
|
+
</template>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- `label`, `count` and `item` arrive as properties; the object is the same object Vue passed, and the element has no `item` attribute. Changing them from Vue state re-renders the Nativele component.
|
|
183
|
+
- `@item-select` uses the exact event name; `e.detail` is typed from the component's `emits`.
|
|
184
|
+
|
|
185
|
+
Children and slots: a `shadow: true` component takes Vue children directly; a Light DOM component (the default) takes one stable element per slot, with Vue changing only what is inside it.
|
|
186
|
+
|
|
187
|
+
```html
|
|
188
|
+
<!-- src/nv/shadow-card.html (and the same in src/nv/light-card.html) -->
|
|
189
|
+
<h2><slot name="title">Untitled</slot></h2>
|
|
190
|
+
<div><slot></slot></div>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
// src/nv/shadow-card.ts
|
|
195
|
+
export class ShadowCard extends Component(tpl, { shadow: true }) {}
|
|
196
|
+
define("nv-shadow-card", ShadowCard);
|
|
197
|
+
|
|
198
|
+
// src/nv/light-card.ts
|
|
199
|
+
export class LightCard extends Component(tpl) {}
|
|
200
|
+
define("nv-light-card", LightCard);
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```vue
|
|
204
|
+
<nv-shadow-card>
|
|
205
|
+
<span v-if="showTitle" slot="title">{{ title }}</span>
|
|
206
|
+
<p v-for="r in rows" :key="r.id">{{ r.text }}</p>
|
|
207
|
+
</nv-shadow-card>
|
|
208
|
+
|
|
209
|
+
<nv-light-card>
|
|
210
|
+
<div slot="title"><span v-if="showTitle">{{ title }}</span></div>
|
|
211
|
+
<div><p v-for="r in rows" :key="r.id">{{ r.text }}</p></div>
|
|
212
|
+
</nv-light-card>
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Known limitations
|
|
216
|
+
|
|
217
|
+
1. **`isCustomElement` must match only the Nativele tags.** With `(tag) => tag.includes("-")`, every kebab-case Vue component (an SFC used as `<hello-box>`, `<router-view>`) stops resolving: it renders as an empty unknown element, with no warning. Use the prefix from step 1. Without `isCustomElement` at all, Nativele tags still render and work, but Vue logs `[Vue warn]: Failed to resolve component: nv-info-card` for each tag in development.
|
|
218
|
+
2. **Define before mount.** Import every Nativele module before `mount()` (step 6). A `:item` binding on an element that is not defined yet becomes the attribute `item="[object Object]"`, and the component gets `null` after `define()` runs. When a module really is loaded later (a dynamic `import()`), bind object props with the property modifier, `.item="item"` (or `:item.prop="item"`): the property is kept and the component takes it over when it is defined.
|
|
219
|
+
3. **camelCase prop names in templates.** Write Nativele props as they are declared: `:selectedItem="item"`, `:maxCount="3"`. Vue sets a kebab-case binding (`:selected-item`) as an attribute, because the element has no property of that name: a number or string still converts from the attribute, an object or array arrives as `"[object Object]"` and the prop stays `null`. `vue-tsc` does not report this (limitation 8).
|
|
220
|
+
4. **Exact event names.** On a custom element, Vue listens to the event name exactly as written after `@`: `@item-select` hears `item-select`, `@pickedOne` hears `pickedOne`, and `@itemSelect` never hears `item-select`. Name events in kebab-case and write that name. Nativele events do not bubble by default, so the listener goes on the element that emits.
|
|
221
|
+
5. **`v-model` does not work on a Nativele component.** It compiles, `vue-tsc` accepts it and Vue logs no warning; it writes the first value into the element, but typing never updates the Vue state. Use a prop and an event, as `nv-text-field` above: `:value="text" @value-change="(e) => (text = e.detail.value)"`. In the component, compare before writing the input's value, so the cursor does not jump (see "A Small Reminder About Form Inputs" in the components guide).
|
|
222
|
+
6. **Light DOM slots and Vue children do not mix.** In the default Light DOM mode, Nativele moves the host's children into its slot positions. When Vue later inserts, removes or reorders those children (`v-if`, `v-for`), the update throws (`Cannot read properties of null (reading 'insertBefore')`, `Cannot set properties of null (setting '__vnode')`) and the slots show the wrong children. Use `shadow: true`, or one stable element per slot as in the example. 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`.
|
|
223
|
+
7. **Lifecycle follows the DOM.** When a keyed `v-for` reorders Nativele elements, Vue moves the same element with `insertBefore`: it runs `disconnected()` then `connected()`, keeps its props and its Light DOM slot children, and `bind` subscriptions end and start again. `<KeepAlive>` deactivation takes the element out of the document (`disconnected()`, no store updates while inactive); activation puts the same element back (`connected()`, and `bind` calls back at once with the current value). `v-if` removal runs `disconnected()` and ends the subscriptions. So subscribe in `connected()`, as the lists guide says for moved items.
|
|
224
|
+
8. **What `vue-tsc` checks.** With the declaration from step 7, it reports a wrong prop type (`count="1"` on a number prop), a wrong object shape and a wrong use of `e.detail`. It does not report an unknown prop name, an unknown tag with the prefix, a kebab-case prop name, `v-model`, or an event name whose case differs from the declared one. In a template, mark an expected error with `<!-- @vue-expect-error -->`; `// @ts-expect-error` is rendered as text and suppresses nothing.
|
|
225
|
+
9. Not verified: server-side rendering and hydration, Nuxt, `<Transition>` around Nativele elements, and Vue versions other than 3.5.
|
|
226
|
+
|
|
227
|
+
## Verify
|
|
228
|
+
|
|
229
|
+
Run in the project:
|
|
230
|
+
|
|
231
|
+
```sh
|
|
232
|
+
npx ts-dom check src/nv # expect: ts-dom check: no issues
|
|
233
|
+
npx ts-dom doctor src/nv # expect: ts-dom doctor: the set-up looks right (info lines are suggestions)
|
|
234
|
+
npx vue-tsc -b # expect: no output
|
|
235
|
+
npx vite build # expect: built
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`ts-dom check` runs `vue-tsc` instead of `tsc` when `vue-tsc` is installed, so it also reports type errors in `.vue` files.
|
|
239
|
+
|
|
240
|
+
Prove that the declaration types props: keep a `.vue` file that the app never imports (the example uses `src/TypesCheck.vue`) with one wrong case per kind under `<!-- @vue-expect-error -->`:
|
|
241
|
+
|
|
242
|
+
```vue
|
|
243
|
+
<template>
|
|
244
|
+
<!-- @vue-expect-error count is a number -->
|
|
245
|
+
<nv-info-card count="1" />
|
|
246
|
+
<!-- @vue-expect-error item has the wrong shape -->
|
|
247
|
+
<nv-info-card :item="{ id: '1' }" />
|
|
248
|
+
<!-- @vue-expect-error detail is { id: number } -->
|
|
249
|
+
<nv-info-card @item-select="(e) => e.detail.name" />
|
|
250
|
+
</template>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`vue-tsc -b` passes only while each of these is an error; if the declaration stops working, it reports `TS2578: Unused '@ts-expect-error' directive`.
|
|
254
|
+
|
|
255
|
+
In the browser, against the dev server (production builds drop Vue warnings), check that an element with an object prop has no attribute for it (`el.getAttributeNames()`) and that `el.item` is the object, that the listener runs once per event, that a kebab-case Vue component still renders, and that there are no errors or `[Vue warn]` lines in the console. The example project's [`verify.mjs`](https://github.com/Pixmi/nativele/blob/main/skills/nativele-vue/example/verify.mjs) does these checks with Playwright, plus the `v-model` replacement, a component defined after mount, children that Vue reorders, adds and removes in both slot modes, and the lifecycle under `v-for`, `<KeepAlive>` and `v-if`. A Light DOM component has no `<slot>` elements once rendered; read a slot with `el.assignedNodes(name)`, where `""` is the default slot, and compare elements only, because Vue leaves comment and empty text nodes among the children.
|
|
256
|
+
|
|
257
|
+
## Troubleshooting
|
|
258
|
+
|
|
259
|
+
| Symptom | Cause | Fix |
|
|
260
|
+
|---|---|---|
|
|
261
|
+
| `TS2307: Cannot find module './x.html.js'` from `npm run build` on a clean checkout | `vue-tsc -b` runs before the generated files exist | start the script with `ts-dom gen src/nv &&` |
|
|
262
|
+
| `[Vue warn]: Failed to resolve component: nv-info-card` | `isCustomElement` is missing, or does not match the tag | set it as in step 4, with the prefix your tags use |
|
|
263
|
+
| A kebab-case Vue component (`<hello-box>`, `<router-view>`) renders as an empty element, with no warning | `isCustomElement` matches every hyphenated tag | match only the Nativele prefix (`tag.startsWith("nv-")`) |
|
|
264
|
+
| An object prop shows `[object Object]`, or the element has an attribute holding it | the element was not defined when Vue rendered it, or the prop is written in kebab-case | import the module before `mount()`; write `:selectedItem`, not `:selected-item`; for a module loaded later, use `.item` |
|
|
265
|
+
| The event listener never runs | the name after `@` differs from the emitted name (`@itemSelect` for `item-select`) | write the exact event name |
|
|
266
|
+
| The Vue value bound with `v-model` never changes | `v-model` does not work on custom elements like this | a prop plus an event (limitation 5) |
|
|
267
|
+
| `Cannot read properties of null (reading 'insertBefore')` or `(setting '__vnode')`, children in the wrong slot | Vue changed children of a Light DOM component with slots | `shadow: true`, or a stable element per slot |
|
|
268
|
+
| `TS7006: Parameter 'e' implicitly has an 'any' type` in an `@item-select` handler, wrong props not reported | the declaration is missing, or the component class is not exported | add `src/nativele-vue.d.ts`, export the class, run `ts-dom gen` |
|
|
269
|
+
| `TS1117: An object literal cannot have multiple properties with the same name` | two spellings of one event on the same element (`@picked-one` and `@pickedOne` are both `onPickedOne` in the types) | keep one, with the exact event name |
|
|
270
|
+
| `ts-dom check` reports `TS2307: Cannot find module './App.vue'` | `vue-tsc` is not installed, so `ts-dom check` runs plain `tsc` | keep `vue-tsc` in `devDependencies` |
|
|
271
|
+
| `ts-dom check` reports `unused-ref` | a `data-ref` the class never reads | use it in the class or remove the attribute |
|
|
272
|
+
|
|
273
|
+
## See also
|
|
274
|
+
|
|
275
|
+
- Components, props and events: https://github.com/Pixmi/nativele/blob/main/docs/components.md#data-flow-props-down-events-up
|
|
276
|
+
- Form inputs written from `render`: https://github.com/Pixmi/nativele/blob/main/docs/components.md#a-small-reminder-about-form-inputs
|
|
277
|
+
- Slots, Light DOM and Shadow DOM: https://github.com/Pixmi/nativele/blob/main/docs/components.md#light-default-and-shadow
|
|
278
|
+
- Moved elements, connecting and subscriptions: https://github.com/Pixmi/nativele/blob/main/docs/lists.md#moving-items-connecting-and-subscriptions
|
|
279
|
+
- Setup, the Vite plugin and checks: https://github.com/Pixmi/nativele/blob/main/docs/tooling.md
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nativele-zod
|
|
3
|
+
description: "Validate a form inside a Nativele component with Zod 4 (or Valibot 1): read the values on submit and on blur, safeParse, map issue paths (also nested and array fields) to per-field messages in state shown with data-if, set aria-invalid and aria-describedby, focus the first invalid field, and emit the parsed data typed with z.infer. Use when a project depends on zod or valibot and @nativele/ts-dom."
|
|
4
|
+
metadata:
|
|
5
|
+
tier: experimental
|
|
6
|
+
nativele: ">=0.5.2 <0.6.0"
|
|
7
|
+
verified: "2026-11-05"
|
|
8
|
+
tools: "zod@4.6.7 valibot@1.5.0 vite@8.3.6 typescript@7.0.2 create-vite@9.3.4"
|
|
9
|
+
author: "Nativele maintainers"
|
|
10
|
+
detects: "zod valibot"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Nativele + Zod
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
- An app that already uses Nativele (`@nativele/ts-dom`) validates a form inside a Nativele component with a schema library: on submit, and optionally when a field loses focus.
|
|
18
|
+
- Signals: `zod` (or `valibot`) and `@nativele/ts-dom` in `package.json`, a component template with a `<form>`.
|
|
19
|
+
- The form has more than a few fields, nested groups (`address.city`), or a repeated field (a list of guest emails). For two or three fields, the hand-written checks in the form validation recipe are enough (See also).
|
|
20
|
+
- Not for learning Zod or Valibot: this Skill covers only how their result reaches a Nativele component. Not for setting up Nativele in Vite (`npx ts-dom skills add nativele-vite`) or writing components in general (`npx ts-dom skills add nativele-core`).
|
|
21
|
+
|
|
22
|
+
## Versions
|
|
23
|
+
|
|
24
|
+
| Tool | Verified version |
|
|
25
|
+
|---|---|
|
|
26
|
+
| @nativele/ts-dom | 0.5.2 |
|
|
27
|
+
| zod | 4.6.7 (the procedure was also run with 3.25.76, with the schema changes in limitation 10) |
|
|
28
|
+
| valibot | 1.5.0 (limitation 11) |
|
|
29
|
+
| vite | 8.3.6 |
|
|
30
|
+
| typescript | 7.0.2 |
|
|
31
|
+
| create-vite (`vanilla-ts` template, the starting point) | 9.3.4 |
|
|
32
|
+
|
|
33
|
+
Verified on 2026-11-05 in Google Chrome 148, with Playwright against both the Vite dev server and `vite preview` of the production build, in a fresh `npm create vite@latest -- --template vanilla-ts` app set up with Nativele by the `nativele-vite` Skill (components in `src/nv/`). Zod 4 is the current major and the one these steps use. With a different major version of Zod or Valibot, treat every step as unverified.
|
|
34
|
+
|
|
35
|
+
## Set-up
|
|
36
|
+
|
|
37
|
+
Start from a Vite app that already uses Nativele, with the components in `src/nv/` and `npx ts-dom check src/nv` passing.
|
|
38
|
+
|
|
39
|
+
1. `npm i zod`.
|
|
40
|
+
2. Keep the schema in its own module next to the component, together with one function that runs `safeParse` and turns the issues into a map from path to message (`signup.ts` in the Example). The component imports only that function and the inferred type, so it never handles Zod objects, and a schema change touches one file.
|
|
41
|
+
3. In the template, give every input a `name` equal to its path in the schema, with dots for nested fields (`name="address.city"`), and the same `name` for every input of an array field (`name="guests"`). After each field, put one message element with an `id`, a `data-if` and a hole, both named `<field>Error` (`<p id="signup-city-error" data-if="cityError">{{ cityError }}</p>`). Add `novalidate` to the `<form>` so the browser does not show its own messages first.
|
|
42
|
+
4. In the component, keep all messages in one state field, `errors`, a map from the dot path to the message, and add one `derive` entry per message element that reads its path (`cityError: (s) => s.errors["address.city"] ?? ""`). Holes and `data-if` take a single name, never a path, so the derived names are what the template sees, and one `set({ errors })` updates every message at once.
|
|
43
|
+
5. Read the values in the handler with `FormData`, building the object the schema expects field by field (limitation 2), and pass it to the function from step 2:
|
|
44
|
+
- on `submit`: `preventDefault()`, validate, `set({ errors })`, then focus the first `[aria-invalid=true]` in the form (rendering is synchronous, so the attributes are already set); when valid, clear `errors` and `emit` the parsed data;
|
|
45
|
+
- on `focusout` of the form (optional): validate the whole form and copy only the message of the field that lost focus;
|
|
46
|
+
- on `input` of the form: clear the message of the field being edited.
|
|
47
|
+
6. In `render`, set `aria-invalid` and `aria-describedby` on each input from `view.errors`, and fill the messages of array items in the list's `replace` callback.
|
|
48
|
+
7. Declare the event with the inferred output type: `emits: { "signup-submit": event<Signup>() }`, where `type Signup = z.infer<typeof SignupSchema>`. The parent then gets the parsed values (here `age` is a `number`), and `emit` with the raw form values is a tsc error.
|
|
49
|
+
|
|
50
|
+
## Example
|
|
51
|
+
|
|
52
|
+
The example links each message by `id`. For a form that can appear more than once on a page, link them with refs instead (limitation 6).
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// src/nv/signup.ts
|
|
56
|
+
import { z } from "zod";
|
|
57
|
+
|
|
58
|
+
export const SignupSchema = z.object({
|
|
59
|
+
name: z.string().trim().min(1, "Name is required"),
|
|
60
|
+
email: z.email("Enter a valid email"),
|
|
61
|
+
age: z.string().min(1, "Age is required").pipe(z.coerce.number<string>("Enter a number").int("Whole years only").min(18, "You must be 18 or older")),
|
|
62
|
+
address: z.object({ city: z.string().trim().min(1, "City is required") }),
|
|
63
|
+
guests: z.array(z.email("Enter a valid email")).max(3, "At most 3 guests"),
|
|
64
|
+
terms: z.boolean().refine((v) => v, "Accept the terms"),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export type Signup = z.infer<typeof SignupSchema>;
|
|
68
|
+
export type Errors = Readonly<Record<string, string>>;
|
|
69
|
+
|
|
70
|
+
export function validateSignup(input: unknown): { ok: true; data: Signup } | { ok: false; errors: Errors } {
|
|
71
|
+
const result = SignupSchema.safeParse(input);
|
|
72
|
+
if (result.success) return { ok: true, data: result.data };
|
|
73
|
+
const errors: Record<string, string> = {};
|
|
74
|
+
for (const issue of result.error.issues) errors[issue.path.join(".")] ??= issue.message;
|
|
75
|
+
return { ok: false, errors };
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```html
|
|
80
|
+
<!-- src/nv/signup-form.html -->
|
|
81
|
+
<form data-ref="form" data-on-submit="submit" data-on-focusout="check" data-on-input="clearError" novalidate>
|
|
82
|
+
<label>Name <input data-ref="name" name="name" autocomplete="name" /></label>
|
|
83
|
+
<p id="signup-name-error" data-if="nameError">{{ nameError }}</p>
|
|
84
|
+
<label>Email <input data-ref="email" name="email" type="email" /></label>
|
|
85
|
+
<p id="signup-email-error" data-if="emailError">{{ emailError }}</p>
|
|
86
|
+
<label>Age <input data-ref="age" name="age" inputmode="numeric" /></label>
|
|
87
|
+
<p id="signup-age-error" data-if="ageError">{{ ageError }}</p>
|
|
88
|
+
<label>City <input data-ref="city" name="address.city" /></label>
|
|
89
|
+
<p id="signup-city-error" data-if="cityError">{{ cityError }}</p>
|
|
90
|
+
<ul>
|
|
91
|
+
<template data-each="guests">
|
|
92
|
+
<li>
|
|
93
|
+
<label>Guest email <input data-ref="guest" name="guests" /></label>
|
|
94
|
+
<button type="button" data-on-click="removeGuest">Remove</button>
|
|
95
|
+
<span data-ref="guestMsg" data-if="invalid">{{ message }}</span>
|
|
96
|
+
</li>
|
|
97
|
+
</template>
|
|
98
|
+
</ul>
|
|
99
|
+
<p id="signup-guests-error" data-if="guestsError">{{ guestsError }}</p>
|
|
100
|
+
<button type="button" data-on-click="addGuest">Add guest</button>
|
|
101
|
+
<label><input data-ref="terms" name="terms" type="checkbox" /> I accept the terms</label>
|
|
102
|
+
<p id="signup-terms-error" data-if="termsError">{{ termsError }}</p>
|
|
103
|
+
<button type="submit">Sign up</button>
|
|
104
|
+
</form>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// src/nv/signup-form.ts
|
|
109
|
+
import { Component, define, event } from "@nativele/ts-dom";
|
|
110
|
+
import type { DeepReadonly, EventHit } from "@nativele/ts-dom";
|
|
111
|
+
import { validateSignup, type Errors, type Signup } from "./signup.js";
|
|
112
|
+
import tpl from "./signup-form.html.js";
|
|
113
|
+
|
|
114
|
+
interface State {
|
|
115
|
+
errors: Errors;
|
|
116
|
+
guests: readonly { id: number }[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readForm(form: HTMLFormElement): unknown {
|
|
120
|
+
const data = new FormData(form);
|
|
121
|
+
return {
|
|
122
|
+
name: data.get("name"),
|
|
123
|
+
email: data.get("email"),
|
|
124
|
+
age: data.get("age"),
|
|
125
|
+
address: { city: data.get("address.city") },
|
|
126
|
+
guests: data.getAll("guests"),
|
|
127
|
+
terms: data.has("terms"),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function pathOf(form: HTMLFormElement, input: HTMLInputElement): string {
|
|
132
|
+
if (input.name !== "guests") return input.name;
|
|
133
|
+
return `guests.${[...form.querySelectorAll("[name=guests]")].indexOf(input)}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class SignupForm extends Component(tpl, {
|
|
137
|
+
state: { errors: {}, guests: [] } as State,
|
|
138
|
+
derive: {
|
|
139
|
+
nameError: (s) => s.errors["name"] ?? "",
|
|
140
|
+
emailError: (s) => s.errors["email"] ?? "",
|
|
141
|
+
ageError: (s) => s.errors["age"] ?? "",
|
|
142
|
+
cityError: (s) => s.errors["address.city"] ?? "",
|
|
143
|
+
guestsError: (s) => s.errors["guests"] ?? "",
|
|
144
|
+
termsError: (s) => s.errors["terms"] ?? "",
|
|
145
|
+
},
|
|
146
|
+
emits: { "signup-submit": event<Signup>() },
|
|
147
|
+
}) {
|
|
148
|
+
#nextGuest = 1;
|
|
149
|
+
|
|
150
|
+
addGuest(): void {
|
|
151
|
+
this.set((s) => ({ guests: [...s.guests, { id: this.#nextGuest++ }], errors: {} }));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
removeGuest(_e: Event, hit: EventHit<{ id: number }, "id">): void {
|
|
155
|
+
this.set((s) => ({ guests: s.guests.filter((g) => g.id !== hit.key), errors: {} }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
submit(e: Event): void {
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
const result = validateSignup(readForm(this.refs.form));
|
|
161
|
+
if (!result.ok) {
|
|
162
|
+
this.set({ errors: result.errors });
|
|
163
|
+
this.refs.form.querySelector<HTMLElement>("[aria-invalid=true]")?.focus();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.set({ errors: {} });
|
|
167
|
+
this.emit("signup-submit", result.data);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
check(e: Event): void {
|
|
171
|
+
if (!(e.target instanceof HTMLInputElement) || e.target.type === "checkbox") return;
|
|
172
|
+
const path = pathOf(this.refs.form, e.target);
|
|
173
|
+
const result = validateSignup(readForm(this.refs.form));
|
|
174
|
+
const message = result.ok ? "" : (result.errors[path] ?? "");
|
|
175
|
+
this.set((s) => ({ errors: { ...s.errors, [path]: message } }));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
clearError(e: Event): void {
|
|
179
|
+
if (!(e.target instanceof HTMLInputElement)) return;
|
|
180
|
+
const path = pathOf(this.refs.form, e.target);
|
|
181
|
+
if (this.state.errors[path]) this.set((s) => ({ errors: { ...s.errors, [path]: "" } }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
override render(view: DeepReadonly<State>): void {
|
|
185
|
+
const mark = (input: HTMLInputElement, path: string, id: string) => {
|
|
186
|
+
const invalid = Boolean(view.errors[path]);
|
|
187
|
+
input.ariaInvalid = invalid ? "true" : null;
|
|
188
|
+
if (invalid) input.setAttribute("aria-describedby", id);
|
|
189
|
+
else input.removeAttribute("aria-describedby");
|
|
190
|
+
};
|
|
191
|
+
mark(this.refs.name, "name", "signup-name-error");
|
|
192
|
+
mark(this.refs.email, "email", "signup-email-error");
|
|
193
|
+
mark(this.refs.age, "age", "signup-age-error");
|
|
194
|
+
mark(this.refs.city, "address.city", "signup-city-error");
|
|
195
|
+
mark(this.refs.terms, "terms", "signup-terms-error");
|
|
196
|
+
this.lists.guests.replace(
|
|
197
|
+
view.guests.map((g, i) => ({ id: g.id, message: view.errors[`guests.${i}`] ?? "" })),
|
|
198
|
+
(g) => g.id,
|
|
199
|
+
(g, { text, show, refs }) => {
|
|
200
|
+
text.message = g.message;
|
|
201
|
+
show("invalid", g.message !== "");
|
|
202
|
+
refs.guest.ariaInvalid = g.message ? "true" : null;
|
|
203
|
+
refs.guest.ariaDescribedByElements = g.message ? [refs.guestMsg] : null;
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
define("signup-form", SignupForm);
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The page that uses it (`index.html` has `<signup-form></signup-form>` and `<pre id="out"></pre>`):
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
// src/main.ts
|
|
215
|
+
import "./nv/signup-form.ts";
|
|
216
|
+
import type { EmitEvent } from "@nativele/ts-dom";
|
|
217
|
+
import type { SignupForm } from "./nv/signup-form.ts";
|
|
218
|
+
|
|
219
|
+
document.querySelector("signup-form")!.addEventListener("signup-submit", (e) => {
|
|
220
|
+
const data = (e as EmitEvent<SignupForm, "signup-submit">).detail;
|
|
221
|
+
document.querySelector("#out")!.textContent = JSON.stringify(data);
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
A file the app never imports, kept so `ts-dom check` proves the event carries the parsed type:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
// src/nv/types-check.ts
|
|
229
|
+
import type { z } from "zod";
|
|
230
|
+
import type { EmitEvent } from "@nativele/ts-dom";
|
|
231
|
+
import type { Signup, SignupSchema } from "./signup.js";
|
|
232
|
+
import type { SignupForm } from "./signup-form.js";
|
|
233
|
+
|
|
234
|
+
declare const submitted: EmitEvent<SignupForm, "signup-submit">;
|
|
235
|
+
declare const raw: z.input<typeof SignupSchema>;
|
|
236
|
+
export const age: number = submitted.detail.age;
|
|
237
|
+
// @ts-expect-error the form's raw input has age as a string, not the parsed number
|
|
238
|
+
export const parsed: Signup = raw;
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
What it does, as checked in the browser:
|
|
242
|
+
|
|
243
|
+
- Submitting the empty form shows every message (`Name is required`, `Enter a valid email`, `Age is required`, `City is required`, `Accept the terms`), sets `aria-invalid="true"` and `aria-describedby` on each invalid input, and focuses the name field. Chrome's accessibility tree then reports the name input as invalid with the description `Name is required`.
|
|
244
|
+
- Typing in a field removes only that field's message and its two attributes. Submitting again focuses the next invalid field in document order.
|
|
245
|
+
- Leaving a field shows its message (`bad` in email shows `Enter a valid email`; age `abc`, `17` and `18.5` show `Enter a number`, `You must be 18 or older` and `Whole years only`).
|
|
246
|
+
- An invalid second guest shows `Enter a valid email` inside that list item only; four guests show `At most 3 guests` under the list.
|
|
247
|
+
- A valid submit clears every message and emits `{ name, email, age: 36, address: { city }, guests: [...], terms: true }`, with `age` a number.
|
|
248
|
+
|
|
249
|
+
## Known limitations
|
|
250
|
+
|
|
251
|
+
1. **One derived name per message.** A hole and a `data-if` take a single identifier, so `{{ errors.name }}` is a template error. Keep the map in state and add a `derive` entry per message element; a name used in the template that is not in `derive` is a `missing-binding` tsc error, which `ts-dom check` reports.
|
|
252
|
+
2. **Build the input object from `FormData` by hand.** In Chrome, `FormData.get` returns the string (`""` for an empty input) or `null` for a name the form does not have, which Zod reports as `Invalid input: expected string, received null`. `Object.fromEntries(new FormData(form))` looks shorter but loses data: repeated names keep only the last value (`guests` became one string), an unchecked checkbox is missing and a checked one is `"on"`, and nested names stay flat keys (`"address.city"`). So read each field: `get` for single values, `getAll` for repeated names, `has` for a checkbox, and nest the object as the schema does. Reading `this.refs.x.value` in a handler works as well; the `unguarded-input-value` lint rule reports only a `render()` that writes a control's value without comparing it first, and neither reading values nor writing them in a handler (`form.reset()` after submit) is reported. This procedure writes no value in `render`.
|
|
253
|
+
3. **Numbers from text inputs.** `z.coerce.number()` turns `""` into `0` (in Zod 4.6.7 and 3.25.76 alike), so an empty age fails with `Too small: expected number to be >=18` instead of a "required" message, or passes when `0` is allowed. Check the string first and coerce after: `z.string().min(1, "Age is required").pipe(z.coerce.number<string>("Enter a number")...)`. Without the `<string>` type argument, Zod 4's `pipe` is a tsc error (`TS2345 ... Type 'unknown' is not assignable to type 'string'`). Zod reports a non-number such as `abc` with the coerce message (`Enter a number`).
|
|
254
|
+
4. **Nested fields** (paths with more than one segment): name the input with the dot path (`name="address.city"`), nest the value in `readForm`, and key the map with `issue.path.join(".")`, so the input name, the issue path and the map key are the same string. Do not use `z.flattenError(error)` (or Zod 3's `error.flatten()`): its `fieldErrors` keep only the first path segment, so `address.city` comes back under `address`.
|
|
255
|
+
5. **Array fields**: an issue inside an array has the index in its path (`guests.1`); one about the array itself (`.max(3)`) has the field name only (`guests`). Show item messages in the `replace` callback of the list, by index, and the array message in its own element under the list. `pathOf` maps a repeated input to its index in document order, which is the order `getAll` returns. Adding or removing an item shifts the indexes, so the example clears `errors` then. An item's elements are not in the component's `refs`, so its input gets `aria-invalid` in the callback, and its description is set with `ariaDescribedByElements`, because an id inside a `data-each` template repeats for every item (`id-in-list`). In Zod 4 a failing `.max()` (or `.min()`) on the array hides the issues of its items: with four guests and `.max(3)`, an empty guest gets no message until the list is short enough; disable the add button at the limit so the case cannot arise.
|
|
256
|
+
6. **Ids repeat when the component is on a page more than once.** In the default Light DOM, each instance renders the same `id`s, and `aria-describedby="signup-name-error"` then finds the first one in the document. For a form that can appear more than once, drop the ids, put a `data-ref` on the message element (`<p data-ref="nameMsg" data-if="nameError">`) and set `this.refs.name.ariaDescribedByElements = view.nameError ? [this.refs.nameMsg] : null` in `render`. This was verified with two instances in Chrome 148 (each input described by its own message; at the component level, unlike in a list item, a ref on a `data-if` element is there even while the block is hidden), and TypeScript 7 types the property; other browsers were not checked.
|
|
257
|
+
7. **Blur does not bubble.** Use `data-on-focusout` on the `<form>`, which receives every field's blur; `e.target` is the field. Validate the whole form and take only that field's message, so checks that read more than one field still apply. The example skips checkboxes on blur, because tabbing past an unchecked box would show its message too early.
|
|
258
|
+
8. **`aria-invalid` needs the value `"true"`.** `toggleAttribute("aria-invalid", on)` writes `aria-invalid=""`, which Chrome's accessibility tree reports as not invalid. Set `input.ariaInvalid = invalid ? "true" : null`. Whether a screen reader announces the message was not checked; the accessibility tree shows each invalid input with its message as the description.
|
|
259
|
+
9. **Checks across fields**: `.refine((d) => d.password === d.confirm, { message: "Passwords differ", path: ["confirm"] })` puts the message under `confirm`. Give such a refinement a `path`; without one its path is empty and the map key is `""`, which no field shows. In Zod 4 the object-level `refine` runs only when every failing field failed a check on a value of the right type (`too_small`, `invalid_format`, a field's own `refine`); a field that fails with `invalid_value` or `invalid_type` stops it, and the cross-field message is missing until that field is fixed. Those are `z.literal(...)` (an unchecked `z.literal(true)` box), `z.enum([...])` given an empty `<select>`, and `z.coerce.number()` given text. Write them so they fail with a check instead: a box as `z.boolean().refine((v) => v, "...")`, a select as `z.string().min(1, "Choose a role").pipe(z.enum([...]))`, a number as in the example (checked with `safeParse` in Node). Valibot's `v.forward(v.partialCheck(...))` does not have this problem.
|
|
260
|
+
10. **Zod 4 vs Zod 3.** These steps use Zod 4. The same component, `verify.mjs` and `issue.path.join(".")` passed with Zod 3.25.76 after these schema changes:
|
|
261
|
+
|
|
262
|
+
| In the schema | Zod 4.6.7 | Zod 3.25.76 |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| email | `z.email("...")` | `z.string().email("...")` (`z.email` does not exist) |
|
|
265
|
+
| coerced number message | `z.coerce.number<string>("...")` | `z.coerce.number({ invalid_type_error: "..." })` (no type argument: `TS2558`) |
|
|
266
|
+
| message of a literal | `z.literal(true, "...")` | `z.literal(true, { errorMap: () => ({ message: "..." }) })` (a string is `TS2559`) |
|
|
267
|
+
| flattening issues | `z.flattenError(error)`, only the first segment (limitation 4) | `error.flatten()`, the same |
|
|
268
|
+
|
|
269
|
+
`safeParse`, `result.error.issues`, `issue.path`, `issue.message` and `z.infer` were used the same way in both.
|
|
270
|
+
|
|
271
|
+
11. **Valibot 1.5.0 fits the same procedure.** Only `signup.ts` changes; the component, template and `verify.mjs` passed unchanged:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
// src/nv/signup.ts with Valibot
|
|
275
|
+
import * as v from "valibot";
|
|
276
|
+
|
|
277
|
+
export const SignupSchema = v.object({
|
|
278
|
+
name: v.pipe(v.string(), v.trim(), v.minLength(1, "Name is required")),
|
|
279
|
+
email: v.pipe(v.string("Enter a valid email"), v.email("Enter a valid email")),
|
|
280
|
+
age: v.pipe(v.string(), v.minLength(1, "Age is required"), v.transform(Number), v.number("Enter a number"), v.integer("Whole years only"), v.minValue(18, "You must be 18 or older")),
|
|
281
|
+
address: v.object({ city: v.pipe(v.string(), v.trim(), v.minLength(1, "City is required")) }),
|
|
282
|
+
guests: v.pipe(v.array(v.pipe(v.string(), v.email("Enter a valid email"))), v.maxLength(3, "At most 3 guests")),
|
|
283
|
+
terms: v.literal(true, "Accept the terms"),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
export type Signup = v.InferOutput<typeof SignupSchema>;
|
|
287
|
+
export type Errors = Readonly<Record<string, string>>;
|
|
288
|
+
|
|
289
|
+
export function validateSignup(input: unknown): { ok: true; data: Signup } | { ok: false; errors: Errors } {
|
|
290
|
+
const result = v.safeParse(SignupSchema, input);
|
|
291
|
+
if (result.success) return { ok: true, data: result.output };
|
|
292
|
+
const errors: Record<string, string> = {};
|
|
293
|
+
for (const issue of result.issues) errors[v.getDotPath(issue) ?? ""] ??= issue.message;
|
|
294
|
+
return { ok: false, errors };
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
For the `types-check.ts` file, use `v.InferInput<typeof SignupSchema>` instead of `z.input`. Valibot's issue path is a list of objects, not keys; `v.getDotPath(issue)` gives the same dot path (`address.city`, `guests.1`). The parsed data is `result.output`, the type `v.InferOutput`. A check across fields is `v.forward(v.partialCheck([["password"], ["confirm"]], (d) => d.password === d.confirm, "Passwords differ"), ["confirm"])` on the object's `v.pipe` (checked in Node). The production bundle of this example page was 26.42 kB (9.55 kB gzip) with Valibot and 106.08 kB (31.88 kB gzip) with Zod 4.
|
|
299
|
+
12. Not verified: Zod Mini, the `zod/v4` entry of Zod 3.25, async refinements (`safeParseAsync`), `<select multiple>`, file inputs, forms inside a `shadow: true` component, the `role="alert"` of the recipe together with `aria-describedby`, screen reader output, and browsers other than Chrome.
|
|
300
|
+
|
|
301
|
+
## Verify
|
|
302
|
+
|
|
303
|
+
Run in the project:
|
|
304
|
+
|
|
305
|
+
```sh
|
|
306
|
+
npx ts-dom check src/nv # expect: ts-dom check: no issues (types-check.ts included)
|
|
307
|
+
npx ts-dom lint src/nv --a11y # expect: ts-dom lint: no issues
|
|
308
|
+
npm run build # expect: ts-dom check: no issues, then ✓ built
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The `@ts-expect-error` in `types-check.ts` and the check below rely on a field whose parsed type differs from its input (here `age`, a string that becomes a number); for a schema without such a transform, input and output types are equal and the directive is reported as unused (`TS2578`), so drop it. Change `event<Signup>()` to `event<z.input<typeof SignupSchema>>()` once to see the type check work: `ts-dom check` must then report `TS2345` at the `emit` (and `TS6133` for the schema's output type, now unused, when `noUnusedLocals` is on) (`Type 'number' is not assignable to type 'string'` for `age`) and `TS2322` in `types-check.ts`.
|
|
312
|
+
|
|
313
|
+
In the browser, against `npm run dev` and against `npx vite preview` of the build, check with a test (Playwright or by hand). Submit with Enter or `form.requestSubmit()` in a test: a message that appears when a field loses focus can move the submit button, and a mouse click aimed at it then misses.
|
|
314
|
+
|
|
315
|
+
- submitting the empty form shows one message per field, sets `aria-invalid="true"` and `aria-describedby` on those inputs, and focuses the first of them;
|
|
316
|
+
- typing in a field removes only its message and both attributes; submitting again focuses the next invalid field;
|
|
317
|
+
- leaving a field with a wrong value shows its message;
|
|
318
|
+
- a wrong item in an array field shows its message in that item only, the item's input is invalid and described by that message in the accessibility tree, and an array-level issue shows under the list;
|
|
319
|
+
- a valid submit emits the parsed values (numbers as numbers), clears every message, and the console has no errors or warnings.
|
|
320
|
+
|
|
321
|
+
## Troubleshooting
|
|
322
|
+
|
|
323
|
+
| Symptom | Cause | Fix |
|
|
324
|
+
|---|---|---|
|
|
325
|
+
| `ts-dom check`: `[missing-binding] "nameMessage" is used by {{ nameMessage }} ... declares no state, prop or derived value of that name`, followed by many `TS7006 ... implicitly has an 'any' type` errors | a hole or `data-if` name that is not a `derive` entry; the other errors follow from it | add the `derive` entry (`nameMessage: (s) => s.errors["name"] ?? ""`) and check again |
|
|
326
|
+
| `ts-dom gen`: `[invalid-hole]` | `{{ errors.name }}` in the template | use the derived name (`{{ nameError }}`) |
|
|
327
|
+
| A nested field never shows its message | the map was built with `z.flattenError` / `error.flatten()`, or the input `name` is not the dot path | key by `issue.path.join(".")` and name the input `address.city` |
|
|
328
|
+
| Every message shows, but the submitted object has one guest instead of all | `Object.fromEntries(new FormData(form))` | `data.getAll("guests")` |
|
|
329
|
+
| An empty number field passes, or fails with `Too small: expected number to be >=...` | `z.coerce.number()` turns `""` into `0` | `z.string().min(1, "...").pipe(z.coerce.number<string>(...))` |
|
|
330
|
+
| tsc `TS2345 ... '_zod.input' are incompatible ... Type 'unknown' is not assignable to type 'string'` on a `pipe` | `z.coerce.number()` without the `<string>` type argument after `z.string()` | `z.coerce.number<string>()` |
|
|
331
|
+
| A field shows `Invalid input: expected string, received null` | `readForm` reads a name the form does not have | match the `get` names to the inputs' `name` attributes |
|
|
332
|
+
| The accessibility tree reports the field as not invalid although its message shows | `aria-invalid=""` from `toggleAttribute` | `input.ariaInvalid = invalid ? "true" : null` |
|
|
333
|
+
| Nothing focuses on submit | the focus query runs before `set`, or `aria-invalid` is not `"true"` | call `set({ errors })` first, then query `[aria-invalid=true]` |
|
|
334
|
+
| The blur message never shows | `data-on-blur` on the form (blur does not bubble) | `data-on-focusout` on the form |
|
|
335
|
+
| After switching Zod versions, the dev server still runs the old one (Zod 4 messages from a Zod 3 schema) | Vite keeps its pre-bundled copy until it restarts | restart `npm run dev`; it prints `Re-optimizing dependencies because lockfile has changed` |
|
|
336
|
+
|
|
337
|
+
## See also
|
|
338
|
+
|
|
339
|
+
- The form validation recipe this extends (messages in state, `data-if`, clearing on input): https://github.com/Pixmi/nativele/blob/main/docs/recipes.md#form-validation
|
|
340
|
+
- `state`, `derive`, `render` and `changed`: https://github.com/Pixmi/nativele/blob/main/docs/components.md#data-and-view-state-optional
|
|
341
|
+
- `emits`, `event<T>()` and `EmitEvent`: https://github.com/Pixmi/nativele/blob/main/docs/components.md#data-flow-props-down-events-up
|
|
342
|
+
- Event delegation, `focusout` and blur: https://github.com/Pixmi/nativele/blob/main/docs/components.md#events-data-on-
|
|
343
|
+
- Writing form control values in `render` (`unguarded-input-value`): https://github.com/Pixmi/nativele/blob/main/docs/components.md#a-small-reminder-about-form-inputs
|
|
344
|
+
- Lists, `replace` and `show` in items: https://github.com/Pixmi/nativele/blob/main/docs/lists.md#items-as-plain-elements-replace
|
|
345
|
+
- Setting up Nativele in the Vite app: `npx ts-dom skills nativele-vite`
|