@nativele/ts-dom 0.5.1 → 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 +33 -0
- package/README.md +6 -1
- package/README.zh-TW.md +6 -1
- package/dist/cli.mjs +1 -1
- package/dist/index.mjs +3 -3
- package/dist/{lint-DfUAh7t3.mjs → lint-Bwytj7L8.mjs} +1 -1
- package/dist/oxlint-plugin.mjs +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/skills/README.md +7 -4
- package/skills/nativele-integration/SKILL.md +92 -22
- package/skills/nativele-state-management/SKILL.md +319 -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-zod/SKILL.md +345 -0
|
@@ -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`
|