@valfuse-node/vue 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +420 -0
- package/dist/index.d.mts +123 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +291 -0
- package/dist/index.mjs +264 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 valfuse-node contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# @valfuse-node/vue
|
|
2
|
+
|
|
3
|
+
> Vue 3 adapter for `@valfuse-node` — `useValfuseForm` composable with reactive `formState`, native v-model bindings, and a 1:1 API contract with the React adapter.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @valfuse-node/vue @valfuse-node/core
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
**Peer dependency:** `vue >= 3`
|
|
10
|
+
|
|
11
|
+
If you want a single install, use the umbrella package:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @valfuse-node/core
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Table of Contents
|
|
20
|
+
|
|
21
|
+
- [Quick Start](#quick-start)
|
|
22
|
+
- [`useValfuseForm(options)`](#usevalfuseformoptions)
|
|
23
|
+
- [Reactive `formState`](#reactive-formstate)
|
|
24
|
+
- [`form.register(name)` — Vue v-model binding](#formregistername--vue-v-model-binding)
|
|
25
|
+
- [`form.handleSubmit(onValid)`](#formhandlesubmitonvalid)
|
|
26
|
+
- [`form.setErrors` / `form.clearErrors`](#formseterrors--formclearerrors)
|
|
27
|
+
- [`form.setValue` / `form.getValue` / `form.getValues`](#formsetvalue--formgetvalue--formgetvalues)
|
|
28
|
+
- [`form.trigger`](#formtrigger)
|
|
29
|
+
- [`form.watch(...)` — multi-overload](#formwatch--multi-overload)
|
|
30
|
+
- [`form.reset(values?)`](#formresetvalues)
|
|
31
|
+
- [`form.control`](#formcontrol)
|
|
32
|
+
- [Custom Inputs](#custom-inputs)
|
|
33
|
+
- [API Parity vs React](#api-parity-vs-react)
|
|
34
|
+
- [Type Reference](#type-reference)
|
|
35
|
+
- [Development Usage](#development-usage)
|
|
36
|
+
- [License](#license)
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```vue
|
|
43
|
+
<script setup lang="ts">
|
|
44
|
+
import { createSchema } from "@valfuse-node/core";
|
|
45
|
+
import { useValfuseForm } from "@valfuse-node/vue";
|
|
46
|
+
|
|
47
|
+
const schema = createSchema({
|
|
48
|
+
email: { type: "string", rules: [{ name: "required", error: { message: "Required" } }, { name: "email", error: { message: "Invalid" } }] },
|
|
49
|
+
password: { type: "string", rules: [{ name: "required", error: { message: "Required" } }, { name: "minLength", value: 8, error: { message: "Min 8" } }] },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
type LoginValues = { email: string; password: string };
|
|
53
|
+
|
|
54
|
+
const form = useValfuseForm<LoginValues>({
|
|
55
|
+
schema,
|
|
56
|
+
defaultValues: { email: "", password: "" },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
async function onSubmit(values: LoginValues) {
|
|
60
|
+
await loginApi(values);
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<form @submit="form.handleSubmit(onSubmit)">
|
|
66
|
+
<input v-bind="form.register('email')" />
|
|
67
|
+
<span v-if="form.formState.errors.email">
|
|
68
|
+
{{ form.formState.errors.email.message }}
|
|
69
|
+
</span>
|
|
70
|
+
|
|
71
|
+
<input type="password" v-bind="form.register('password')" />
|
|
72
|
+
<span v-if="form.formState.errors.password">
|
|
73
|
+
{{ form.formState.errors.password.message }}
|
|
74
|
+
</span>
|
|
75
|
+
|
|
76
|
+
<button type="submit" :disabled="form.formState.isSubmitting">Log in</button>
|
|
77
|
+
</form>
|
|
78
|
+
</template>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## `useValfuseForm(options)`
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
function useValfuseForm<TFieldValues extends Record<string, unknown>>(
|
|
87
|
+
options: UseValfuseFormProps<TFieldValues>
|
|
88
|
+
): UseValfuseFormReturn<TFieldValues>;
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Options
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
interface UseValfuseFormProps<TFieldValues> {
|
|
95
|
+
schema: ValfuseSchema; // required
|
|
96
|
+
defaultValues: TFieldValues; // required (inferred)
|
|
97
|
+
mode?: "onSubmit" | "onChange" | "onBlur" | "onTouched" | "all"; // default: "onSubmit"
|
|
98
|
+
reValidateMode?: "onChange" | "onBlur" | "onSubmit"; // default: "onChange"
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| Option | Type | Default | Notes |
|
|
103
|
+
|---|---|---|---|
|
|
104
|
+
| `schema` | `ValfuseSchema` | — (required) | The rule-based schema from `@valfuse-node/form` |
|
|
105
|
+
| `defaultValues` | object literal | — (required) | The generic `TFieldValues` is **inferred** from this |
|
|
106
|
+
| `mode` | union | `"onSubmit"` | When validation first runs |
|
|
107
|
+
| `reValidateMode` | union | `"onChange"` | Mode used after the first submit attempt to re-validate fields the user fixes |
|
|
108
|
+
|
|
109
|
+
### Return value
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
interface UseValfuseFormReturn<TFieldValues> {
|
|
113
|
+
formState: ValfuseFormState<TFieldValues>; // reactive
|
|
114
|
+
control: ValfuseFormControl<TFieldValues>;
|
|
115
|
+
register: (name) => { name, modelValue, "onUpdate:modelValue", onBlur };
|
|
116
|
+
handleSubmit:(onValid) => (e: Event) => Promise<void>;
|
|
117
|
+
setErrors: (errors) => void;
|
|
118
|
+
clearErrors: (fields?) => void;
|
|
119
|
+
setValue: (name, value) => void;
|
|
120
|
+
getValue: (name) => TFieldValues[TName];
|
|
121
|
+
getValues: () => TFieldValues;
|
|
122
|
+
trigger: (name?) => boolean;
|
|
123
|
+
watch: ValfuseVueWatchFunction<TFieldValues>;
|
|
124
|
+
reset: (values?) => void;
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Reactive `formState`
|
|
131
|
+
|
|
132
|
+
`formState` is a `reactive()` proxy — every field is a getter so Vue tracks reads and triggers re-renders.
|
|
133
|
+
|
|
134
|
+
| Field | Type | Description |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `errors` | `Partial<Record<keyof T, ValfuseFieldError>>` | Current field errors |
|
|
137
|
+
| `isSubmitting` | `boolean` | `true` while the async submit handler is running |
|
|
138
|
+
| `isSubmitted` | `boolean` | `true` after the first submit attempt |
|
|
139
|
+
| `isSubmitSuccessful` | `boolean` | `true` if the most recent submit completed without throwing |
|
|
140
|
+
| `submitCount` | `number` | Total submit attempts |
|
|
141
|
+
| `isDirty` | `boolean` | `true` if any field differs from `defaultValues` |
|
|
142
|
+
| `isValid` | `boolean` | `true` when no errors are present |
|
|
143
|
+
| `dirtyFields` | `Partial<Record<keyof T, true>>` | Fields that differ from `defaultValues` |
|
|
144
|
+
| `touchedFields` | `Partial<Record<keyof T, true>>` | Fields the user has blurred |
|
|
145
|
+
| `defaultValues` | `Readonly<T>` | The defaults passed at composable initialization |
|
|
146
|
+
|
|
147
|
+
```vue
|
|
148
|
+
<template>
|
|
149
|
+
<div v-if="form.formState.isSubmitting">Saving…</div>
|
|
150
|
+
<div v-if="form.formState.isDirty">Unsaved changes</div>
|
|
151
|
+
<pre>{{ form.formState.errors }}</pre>
|
|
152
|
+
</template>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## `form.register(name)` — Vue v-model binding
|
|
158
|
+
|
|
159
|
+
Returns Vue-native v-model props. Use `v-bind` to spread them onto an element.
|
|
160
|
+
|
|
161
|
+
```vue
|
|
162
|
+
<input v-bind="form.register('email')" />
|
|
163
|
+
<!--
|
|
164
|
+
expands to:
|
|
165
|
+
:name="email"
|
|
166
|
+
:modelValue="email value"
|
|
167
|
+
@update:modelValue="..."
|
|
168
|
+
@blur="..."
|
|
169
|
+
-->
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The shape is `{ name, modelValue, "onUpdate:modelValue", onBlur }`. This is **intentionally different** from the React adapter — Vue's idiomatic binding is v-model, not onChange.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## `form.handleSubmit(onValid)`
|
|
177
|
+
|
|
178
|
+
```vue
|
|
179
|
+
<script setup lang="ts">
|
|
180
|
+
const onSubmit = form.handleSubmit(async (values) => {
|
|
181
|
+
// values: TFieldValues (already transformed + validated)
|
|
182
|
+
await api.save(values);
|
|
183
|
+
});
|
|
184
|
+
</script>
|
|
185
|
+
|
|
186
|
+
<template>
|
|
187
|
+
<form @submit="onSubmit">…</form>
|
|
188
|
+
</template>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The handler:
|
|
192
|
+
1. Calls `e.preventDefault()` on the submit event
|
|
193
|
+
2. Validates the form
|
|
194
|
+
3. If valid → calls `onValid(values)` and sets `isSubmitting = true` for the duration
|
|
195
|
+
4. If invalid → sets `formState.errors` and returns
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## `form.setErrors` / `form.clearErrors`
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
form.setErrors({ email: { message: "Account exists", code: "auth.duplicate" } });
|
|
203
|
+
form.clearErrors(); // clear all
|
|
204
|
+
form.clearErrors(["email", "password"]); // clear specific fields
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`form.setErrors` accepts a `SetErrorsInput` — pass either a string (`{ email: "Required" }`) or a `ValfuseError` object (`{ email: { message, code, type } }`). Strings are normalized to objects automatically.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## `form.setValue` / `form.getValue` / `form.getValues`
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
form.setValue("email", "alice@example.com");
|
|
215
|
+
|
|
216
|
+
const current = form.getValue("email");
|
|
217
|
+
const all = form.getValues();
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`getValue` and `getValues` are **Vue-specific extensions** not present in the React adapter. They return snapshots — mutating them has no effect on the form.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## `form.trigger`
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
form.trigger(); // all fields
|
|
228
|
+
form.trigger("email"); // one field
|
|
229
|
+
form.trigger(["email", "password"]); // many
|
|
230
|
+
// returns boolean — true if all triggered fields are valid
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Diff-merges results into `formState.errors` without clobbering unrelated field errors.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## `form.watch(...)` — multi-overload
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
const all = form.watch(); // TFieldValues snapshot
|
|
241
|
+
const email = form.watch("email"); // TFieldValues["email"]
|
|
242
|
+
const pair = form.watch(["email", "name"]); // Array of values
|
|
243
|
+
|
|
244
|
+
const unsub = form.watch((values, info) => { // subscribe to all changes
|
|
245
|
+
console.log("changed:", info?.name, values);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const unsubField = form.watch("email", (value) => { // legacy: subscribe to one field
|
|
249
|
+
console.log("email is now", value);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// later
|
|
253
|
+
unsub();
|
|
254
|
+
unsubField();
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
> **Tip:** the `watch(callback)` form (global subscription) is preferred over the legacy `watch(name, callback)` form. The legacy form is preserved for backward compatibility.
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## `form.reset(values?)`
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
form.reset(); // back to defaultValues
|
|
265
|
+
form.reset({ email: "" }); // partial override
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Resets values, errors, touched, dirty, **and** submission state (`isSubmitted`, `isSubmitSuccessful`, `submitCount`).
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## `form.control`
|
|
273
|
+
|
|
274
|
+
Same shape as the React adapter's `control` — an opaque object you can pass to a future `<ValfuseController>` Vue equivalent. The `_values`, `_errors`, `_touchedFields` getters always return the latest snapshot.
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
form.control._values; // current values
|
|
278
|
+
form.control._errors; // current errors
|
|
279
|
+
form.control._touchedFields; // ReadonlySet of touched field names
|
|
280
|
+
form.control._updateField(name, value);
|
|
281
|
+
form.control._touchField(name);
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Custom Inputs
|
|
287
|
+
|
|
288
|
+
Vue does not yet ship a `ValfuseController` component, so custom inputs use `form.getValue` / `form.setValue` directly.
|
|
289
|
+
|
|
290
|
+
```vue
|
|
291
|
+
<script setup lang="ts">
|
|
292
|
+
const role = computed({
|
|
293
|
+
get: () => form.getValue("role") as string,
|
|
294
|
+
set: (v) => form.setValue("role", v),
|
|
295
|
+
});
|
|
296
|
+
</script>
|
|
297
|
+
|
|
298
|
+
<template>
|
|
299
|
+
<select v-model="role">
|
|
300
|
+
<option value="admin">Admin</option>
|
|
301
|
+
<option value="user">User</option>
|
|
302
|
+
</select>
|
|
303
|
+
</template>
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
This works because `form.getValue` reads the latest value and `form.setValue` writes through the same internal store that `form.register` uses, so dirty/touched/watch all stay in sync.
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## API Parity vs React
|
|
311
|
+
|
|
312
|
+
| Feature | React (`@valfuse-node/react`) | Vue (`@valfuse-node/vue`) |
|
|
313
|
+
|---|---|---|
|
|
314
|
+
| Field binding | `{...form.register('f')}` (JSX spread) | `v-bind="form.register('f')"` |
|
|
315
|
+
| Custom field | `<ValfuseController>` + `form.control` | `getValue` / `setValue` (no controller yet) |
|
|
316
|
+
| Watch snapshot | `form.watch()` | `form.watch()` |
|
|
317
|
+
| Watch subscribe (global) | `form.watch((values, info) => …)` | `form.watch((values, info) => …)` |
|
|
318
|
+
| Watch subscribe (one field) | `form.watch("email", cb)` | `form.watch("email", cb)` (legacy) |
|
|
319
|
+
| Watch subscribe (multi) | `form.watch(["a", "b"], cb)` | — use multiple single-field subscriptions |
|
|
320
|
+
| Manual trigger | `form.trigger()` | `form.trigger()` |
|
|
321
|
+
| Localization runtime | `<LocalizationProvider>` + `useLocalization()` | — not provided; use the underlying `@valfuse-node/localization` package |
|
|
322
|
+
| Mode values | `onSubmit \| onChange \| onBlur \| onTouched \| all` | `onSubmit \| onChange \| onBlur \| onTouched \| all` |
|
|
323
|
+
|
|
324
|
+
The form contract (`UseValfuseFormReturn`) is **identical at the type level** between React and Vue, so the same schema and the same `defaultValues` can be reused across adapters.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Type Reference
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
import type {
|
|
332
|
+
UseValfuseFormProps,
|
|
333
|
+
UseValfuseFormReturn,
|
|
334
|
+
ValfuseFormMode,
|
|
335
|
+
ValfuseFormState,
|
|
336
|
+
ValfuseRegisterReturn,
|
|
337
|
+
} from "@valfuse-node/vue";
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
## Development Usage
|
|
343
|
+
|
|
344
|
+
### Share a schema across web (React) and mobile (Vue)
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
// schemas/user.ts
|
|
348
|
+
import { createSchema } from "@valfuse-node/form";
|
|
349
|
+
|
|
350
|
+
export const userSchema = createSchema({
|
|
351
|
+
name: { type: "string", rules: [{ name: "required", error: { message: "Required" } }] },
|
|
352
|
+
email: { type: "string", rules: [{ name: "required", error: { message: "Required" } }, { name: "email", error: { message: "Invalid" } }] },
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
export type UserValues = { name: string; email: string };
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
```vue
|
|
359
|
+
<!-- Vue component -->
|
|
360
|
+
<script setup lang="ts">
|
|
361
|
+
import { useValfuseForm } from "@valfuse-node/vue";
|
|
362
|
+
import { userSchema, type UserValues } from "~/schemas/user";
|
|
363
|
+
|
|
364
|
+
const form = useValfuseForm<UserValues>({
|
|
365
|
+
schema: userSchema,
|
|
366
|
+
defaultValues: { name: "", email: "" },
|
|
367
|
+
mode: "onBlur",
|
|
368
|
+
});
|
|
369
|
+
</script>
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Use a validation mode that matches UX intent
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
// Aggressive — validates on every keystroke
|
|
376
|
+
useValfuseForm({ schema, defaultValues, mode: "onChange" });
|
|
377
|
+
|
|
378
|
+
// Friendly — validates on blur (recommended for most forms)
|
|
379
|
+
useValfuseForm({ schema, defaultValues, mode: "onBlur" });
|
|
380
|
+
|
|
381
|
+
// Lazy — only validates on submit
|
|
382
|
+
useValfuseForm({ schema, defaultValues, mode: "onSubmit" });
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Inject server errors
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
async function onSubmit(values: UserValues) {
|
|
389
|
+
try {
|
|
390
|
+
await api.saveUser(values);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
form.setErrors({
|
|
393
|
+
email: { message: "Email is already taken", code: "auth.duplicate", type: "server" },
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### Build a reactive debug panel
|
|
400
|
+
|
|
401
|
+
```vue
|
|
402
|
+
<script setup lang="ts">
|
|
403
|
+
import { computed } from "vue";
|
|
404
|
+
|
|
405
|
+
const values = computed(() => form.getValues());
|
|
406
|
+
const dirty = computed(() => form.formState.dirtyFields);
|
|
407
|
+
</script>
|
|
408
|
+
|
|
409
|
+
<template>
|
|
410
|
+
<pre>values: {{ values }}</pre>
|
|
411
|
+
<pre>dirty: {{ dirty }}</pre>
|
|
412
|
+
<pre>valid: {{ form.formState.isValid }}</pre>
|
|
413
|
+
</template>
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## License
|
|
419
|
+
|
|
420
|
+
[MIT](../../LICENSE)
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ValfuseSchema, ValfuseFormErrors, ValfuseDirtyFields, ValfuseTouchedFields, ValfuseFormControl, SetErrorsInput, ValfuseWatchFunction } from '@valfuse-node/form';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Vue-specific watch function with the additional legacy `watch(name, callback)`
|
|
5
|
+
* form. Mirrors `ValfuseWatchFunction` 1:1 plus:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* form.watch("email", (value) => { ... }) // → () => void (unsubscribe)
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* The legacy form is kept for backward compat with pre-contract code; new code
|
|
12
|
+
* should prefer `form.watch((values, info) => { ... })` so the callback
|
|
13
|
+
* signature matches the form-domain `ValfuseWatchCallback`.
|
|
14
|
+
*/
|
|
15
|
+
interface ValfuseVueWatchFunction<TFieldValues extends Record<string, unknown>> extends ValfuseWatchFunction<TFieldValues> {
|
|
16
|
+
(name: keyof TFieldValues & string, callback: (value: unknown) => void): () => void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Validation trigger mode.
|
|
20
|
+
*
|
|
21
|
+
* Mirrors the React adapter and the form-domain contract exactly.
|
|
22
|
+
* `onTouched` is supported by React; for Vue we currently support the same
|
|
23
|
+
* three core modes. Extend when needed.
|
|
24
|
+
*/
|
|
25
|
+
type ValfuseFormMode = "onSubmit" | "onChange" | "onBlur" | "onTouched" | "all";
|
|
26
|
+
/**
|
|
27
|
+
* Options for useValfuseForm.
|
|
28
|
+
*
|
|
29
|
+
* `TFieldValues` is the **values shape** (not the schema shape) — inferred
|
|
30
|
+
* from `defaultValues`. This matches the React adapter and the form-domain
|
|
31
|
+
* contract, so a consumer can swap frameworks without rewriting the call site.
|
|
32
|
+
*/
|
|
33
|
+
interface UseValfuseFormProps<TFieldValues extends Record<string, unknown>> {
|
|
34
|
+
schema: ValfuseSchema;
|
|
35
|
+
defaultValues: TFieldValues;
|
|
36
|
+
mode?: ValfuseFormMode;
|
|
37
|
+
/** Mode used to re-validate a field after the first submit attempt. */
|
|
38
|
+
reValidateMode?: ValfuseFormMode;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Reactive form state exposed by useValfuseForm.
|
|
42
|
+
*
|
|
43
|
+
* Shape mirrors the form-domain `ValfuseFormState` exactly so a consumer can
|
|
44
|
+
* swap adapters without re-learning the API. The `readonly` modifiers are
|
|
45
|
+
* documentation — Vue cannot enforce them at runtime on a reactive proxy,
|
|
46
|
+
* but consumers should treat the state as read-only.
|
|
47
|
+
*/
|
|
48
|
+
interface ValfuseFormState<TFieldValues extends Record<string, unknown>> {
|
|
49
|
+
readonly errors: ValfuseFormErrors<TFieldValues>;
|
|
50
|
+
readonly isSubmitting: boolean;
|
|
51
|
+
readonly isSubmitted: boolean;
|
|
52
|
+
readonly isSubmitSuccessful: boolean;
|
|
53
|
+
readonly submitCount: number;
|
|
54
|
+
readonly isDirty: boolean;
|
|
55
|
+
readonly isValid: boolean;
|
|
56
|
+
readonly dirtyFields: ValfuseDirtyFields<TFieldValues>;
|
|
57
|
+
readonly touchedFields: ValfuseTouchedFields<TFieldValues>;
|
|
58
|
+
readonly defaultValues: Readonly<TFieldValues>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Props returned by `form.register(name)` for the Vue adapter.
|
|
62
|
+
*
|
|
63
|
+
* Shape is Vue-native (v-model): spread the result onto an element with
|
|
64
|
+
* `v-bind` and `v-model` will work out of the box. This is intentionally
|
|
65
|
+
* different from the React adapter's `ValfuseRegisterReturn` — Vue's
|
|
66
|
+
* idiomatic binding shape uses `modelValue` + `onUpdate:modelValue`.
|
|
67
|
+
*/
|
|
68
|
+
interface ValfuseRegisterReturn {
|
|
69
|
+
name: string;
|
|
70
|
+
modelValue: unknown;
|
|
71
|
+
"onUpdate:modelValue": (value: unknown) => void;
|
|
72
|
+
onBlur: () => void;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Return type of useValfuseForm.
|
|
76
|
+
*
|
|
77
|
+
* Mirrors the form-domain `UseValfuseFormReturn` contract, with two
|
|
78
|
+
* adapter-specific extensions:
|
|
79
|
+
* - `register` returns the Vue-native `ValfuseRegisterReturn` (v-model shape).
|
|
80
|
+
* - `getValue` and `getValues` are convenience getters not present in React.
|
|
81
|
+
*
|
|
82
|
+
* All other members match the React contract 1:1. In particular, `control`
|
|
83
|
+
* is the same shape used by React's `<ValfuseController>` — a future
|
|
84
|
+
* Vue equivalent component will accept it identically.
|
|
85
|
+
*/
|
|
86
|
+
interface UseValfuseFormReturn<TFieldValues extends Record<string, unknown>> {
|
|
87
|
+
formState: ValfuseFormState<TFieldValues>;
|
|
88
|
+
control: ValfuseFormControl<TFieldValues>;
|
|
89
|
+
register: (name: keyof TFieldValues & string) => ValfuseRegisterReturn;
|
|
90
|
+
handleSubmit: (fn: (values: TFieldValues) => Promise<void> | void) => (e: Event) => Promise<void>;
|
|
91
|
+
setErrors: (errors: SetErrorsInput) => void;
|
|
92
|
+
clearErrors: (fields?: Array<keyof TFieldValues & string>) => void;
|
|
93
|
+
setValue: (name: keyof TFieldValues & string, value: unknown) => void;
|
|
94
|
+
trigger: (name?: keyof TFieldValues & string | Array<keyof TFieldValues & string>) => boolean;
|
|
95
|
+
watch: ValfuseVueWatchFunction<TFieldValues>;
|
|
96
|
+
reset: (values?: Partial<TFieldValues>) => void;
|
|
97
|
+
/** Vue-specific extension: read a single field value. */
|
|
98
|
+
getValue: (name: keyof TFieldValues & string) => unknown;
|
|
99
|
+
/** Vue-specific extension: read all field values. */
|
|
100
|
+
getValues: () => TFieldValues;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* useValfuseForm — Vue composable.
|
|
105
|
+
*
|
|
106
|
+
* Public API contract matches the React adapter and the form-domain
|
|
107
|
+
* `UseValfuseFormReturn` interface. The two adapter-specific differences:
|
|
108
|
+
* 1. `register()` returns Vue-native v-model props
|
|
109
|
+
* (`modelValue` + `onUpdate:modelValue`).
|
|
110
|
+
* 2. `getValue(name)` and `getValues()` are convenience getters that
|
|
111
|
+
* the React adapter does not expose.
|
|
112
|
+
*
|
|
113
|
+
* Internal state uses Sets for O(1) add/delete on dirty/touched tracking,
|
|
114
|
+
* but the public `formState` exposes them as Record-shaped
|
|
115
|
+
* `{ [K]?: true }` to match the form contract.
|
|
116
|
+
*
|
|
117
|
+
* Usage:
|
|
118
|
+
* const form = useValfuseForm({ schema, defaultValues })
|
|
119
|
+
* <input v-bind="form.register('email')" />
|
|
120
|
+
*/
|
|
121
|
+
declare function useValfuseForm<TFieldValues extends Record<string, unknown>>(options: UseValfuseFormProps<TFieldValues>): UseValfuseFormReturn<TFieldValues>;
|
|
122
|
+
|
|
123
|
+
export { type UseValfuseFormProps, type UseValfuseFormReturn, type ValfuseFormMode, type ValfuseFormState, type ValfuseRegisterReturn, useValfuseForm };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ValfuseSchema, ValfuseFormErrors, ValfuseDirtyFields, ValfuseTouchedFields, ValfuseFormControl, SetErrorsInput, ValfuseWatchFunction } from '@valfuse-node/form';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Vue-specific watch function with the additional legacy `watch(name, callback)`
|
|
5
|
+
* form. Mirrors `ValfuseWatchFunction` 1:1 plus:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* form.watch("email", (value) => { ... }) // → () => void (unsubscribe)
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* The legacy form is kept for backward compat with pre-contract code; new code
|
|
12
|
+
* should prefer `form.watch((values, info) => { ... })` so the callback
|
|
13
|
+
* signature matches the form-domain `ValfuseWatchCallback`.
|
|
14
|
+
*/
|
|
15
|
+
interface ValfuseVueWatchFunction<TFieldValues extends Record<string, unknown>> extends ValfuseWatchFunction<TFieldValues> {
|
|
16
|
+
(name: keyof TFieldValues & string, callback: (value: unknown) => void): () => void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Validation trigger mode.
|
|
20
|
+
*
|
|
21
|
+
* Mirrors the React adapter and the form-domain contract exactly.
|
|
22
|
+
* `onTouched` is supported by React; for Vue we currently support the same
|
|
23
|
+
* three core modes. Extend when needed.
|
|
24
|
+
*/
|
|
25
|
+
type ValfuseFormMode = "onSubmit" | "onChange" | "onBlur" | "onTouched" | "all";
|
|
26
|
+
/**
|
|
27
|
+
* Options for useValfuseForm.
|
|
28
|
+
*
|
|
29
|
+
* `TFieldValues` is the **values shape** (not the schema shape) — inferred
|
|
30
|
+
* from `defaultValues`. This matches the React adapter and the form-domain
|
|
31
|
+
* contract, so a consumer can swap frameworks without rewriting the call site.
|
|
32
|
+
*/
|
|
33
|
+
interface UseValfuseFormProps<TFieldValues extends Record<string, unknown>> {
|
|
34
|
+
schema: ValfuseSchema;
|
|
35
|
+
defaultValues: TFieldValues;
|
|
36
|
+
mode?: ValfuseFormMode;
|
|
37
|
+
/** Mode used to re-validate a field after the first submit attempt. */
|
|
38
|
+
reValidateMode?: ValfuseFormMode;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Reactive form state exposed by useValfuseForm.
|
|
42
|
+
*
|
|
43
|
+
* Shape mirrors the form-domain `ValfuseFormState` exactly so a consumer can
|
|
44
|
+
* swap adapters without re-learning the API. The `readonly` modifiers are
|
|
45
|
+
* documentation — Vue cannot enforce them at runtime on a reactive proxy,
|
|
46
|
+
* but consumers should treat the state as read-only.
|
|
47
|
+
*/
|
|
48
|
+
interface ValfuseFormState<TFieldValues extends Record<string, unknown>> {
|
|
49
|
+
readonly errors: ValfuseFormErrors<TFieldValues>;
|
|
50
|
+
readonly isSubmitting: boolean;
|
|
51
|
+
readonly isSubmitted: boolean;
|
|
52
|
+
readonly isSubmitSuccessful: boolean;
|
|
53
|
+
readonly submitCount: number;
|
|
54
|
+
readonly isDirty: boolean;
|
|
55
|
+
readonly isValid: boolean;
|
|
56
|
+
readonly dirtyFields: ValfuseDirtyFields<TFieldValues>;
|
|
57
|
+
readonly touchedFields: ValfuseTouchedFields<TFieldValues>;
|
|
58
|
+
readonly defaultValues: Readonly<TFieldValues>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Props returned by `form.register(name)` for the Vue adapter.
|
|
62
|
+
*
|
|
63
|
+
* Shape is Vue-native (v-model): spread the result onto an element with
|
|
64
|
+
* `v-bind` and `v-model` will work out of the box. This is intentionally
|
|
65
|
+
* different from the React adapter's `ValfuseRegisterReturn` — Vue's
|
|
66
|
+
* idiomatic binding shape uses `modelValue` + `onUpdate:modelValue`.
|
|
67
|
+
*/
|
|
68
|
+
interface ValfuseRegisterReturn {
|
|
69
|
+
name: string;
|
|
70
|
+
modelValue: unknown;
|
|
71
|
+
"onUpdate:modelValue": (value: unknown) => void;
|
|
72
|
+
onBlur: () => void;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Return type of useValfuseForm.
|
|
76
|
+
*
|
|
77
|
+
* Mirrors the form-domain `UseValfuseFormReturn` contract, with two
|
|
78
|
+
* adapter-specific extensions:
|
|
79
|
+
* - `register` returns the Vue-native `ValfuseRegisterReturn` (v-model shape).
|
|
80
|
+
* - `getValue` and `getValues` are convenience getters not present in React.
|
|
81
|
+
*
|
|
82
|
+
* All other members match the React contract 1:1. In particular, `control`
|
|
83
|
+
* is the same shape used by React's `<ValfuseController>` — a future
|
|
84
|
+
* Vue equivalent component will accept it identically.
|
|
85
|
+
*/
|
|
86
|
+
interface UseValfuseFormReturn<TFieldValues extends Record<string, unknown>> {
|
|
87
|
+
formState: ValfuseFormState<TFieldValues>;
|
|
88
|
+
control: ValfuseFormControl<TFieldValues>;
|
|
89
|
+
register: (name: keyof TFieldValues & string) => ValfuseRegisterReturn;
|
|
90
|
+
handleSubmit: (fn: (values: TFieldValues) => Promise<void> | void) => (e: Event) => Promise<void>;
|
|
91
|
+
setErrors: (errors: SetErrorsInput) => void;
|
|
92
|
+
clearErrors: (fields?: Array<keyof TFieldValues & string>) => void;
|
|
93
|
+
setValue: (name: keyof TFieldValues & string, value: unknown) => void;
|
|
94
|
+
trigger: (name?: keyof TFieldValues & string | Array<keyof TFieldValues & string>) => boolean;
|
|
95
|
+
watch: ValfuseVueWatchFunction<TFieldValues>;
|
|
96
|
+
reset: (values?: Partial<TFieldValues>) => void;
|
|
97
|
+
/** Vue-specific extension: read a single field value. */
|
|
98
|
+
getValue: (name: keyof TFieldValues & string) => unknown;
|
|
99
|
+
/** Vue-specific extension: read all field values. */
|
|
100
|
+
getValues: () => TFieldValues;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* useValfuseForm — Vue composable.
|
|
105
|
+
*
|
|
106
|
+
* Public API contract matches the React adapter and the form-domain
|
|
107
|
+
* `UseValfuseFormReturn` interface. The two adapter-specific differences:
|
|
108
|
+
* 1. `register()` returns Vue-native v-model props
|
|
109
|
+
* (`modelValue` + `onUpdate:modelValue`).
|
|
110
|
+
* 2. `getValue(name)` and `getValues()` are convenience getters that
|
|
111
|
+
* the React adapter does not expose.
|
|
112
|
+
*
|
|
113
|
+
* Internal state uses Sets for O(1) add/delete on dirty/touched tracking,
|
|
114
|
+
* but the public `formState` exposes them as Record-shaped
|
|
115
|
+
* `{ [K]?: true }` to match the form contract.
|
|
116
|
+
*
|
|
117
|
+
* Usage:
|
|
118
|
+
* const form = useValfuseForm({ schema, defaultValues })
|
|
119
|
+
* <input v-bind="form.register('email')" />
|
|
120
|
+
*/
|
|
121
|
+
declare function useValfuseForm<TFieldValues extends Record<string, unknown>>(options: UseValfuseFormProps<TFieldValues>): UseValfuseFormReturn<TFieldValues>;
|
|
122
|
+
|
|
123
|
+
export { type UseValfuseFormProps, type UseValfuseFormReturn, type ValfuseFormMode, type ValfuseFormState, type ValfuseRegisterReturn, useValfuseForm };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
useValfuseForm: () => useValfuseForm
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/composables/use-valfuse-form.ts
|
|
28
|
+
var import_vue = require("vue");
|
|
29
|
+
var import_form = require("@valfuse-node/form");
|
|
30
|
+
function useValfuseForm(options) {
|
|
31
|
+
const { schema, defaultValues, mode = "onSubmit", reValidateMode = "onChange" } = options;
|
|
32
|
+
const values = (0, import_vue.reactive)({ ...defaultValues });
|
|
33
|
+
const errors = (0, import_vue.ref)({});
|
|
34
|
+
const isSubmitting = (0, import_vue.ref)(false);
|
|
35
|
+
const isSubmitted = (0, import_vue.ref)(false);
|
|
36
|
+
const isSubmitSuccessful = (0, import_vue.ref)(false);
|
|
37
|
+
const submitCount = (0, import_vue.ref)(0);
|
|
38
|
+
const dirtySet = (0, import_vue.shallowRef)(/* @__PURE__ */ new Set());
|
|
39
|
+
const touchedSet = (0, import_vue.shallowRef)(/* @__PURE__ */ new Set());
|
|
40
|
+
const fieldWatchers = /* @__PURE__ */ new Map();
|
|
41
|
+
const globalWatchers = /* @__PURE__ */ new Set();
|
|
42
|
+
function runValidation() {
|
|
43
|
+
const typed = (0, import_form.transformValues)(schema, values);
|
|
44
|
+
return (0, import_form.validateSchema)(schema, typed);
|
|
45
|
+
}
|
|
46
|
+
function setToRecord(set) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const k of set) out[k] = true;
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function markDirty(key, newValue) {
|
|
52
|
+
const dirty = new Set(dirtySet.value);
|
|
53
|
+
if (newValue !== defaultValues[key]) {
|
|
54
|
+
dirty.add(key);
|
|
55
|
+
} else {
|
|
56
|
+
dirty.delete(key);
|
|
57
|
+
}
|
|
58
|
+
dirtySet.value = dirty;
|
|
59
|
+
}
|
|
60
|
+
function markTouched(key) {
|
|
61
|
+
const touched = new Set(touchedSet.value);
|
|
62
|
+
touched.add(key);
|
|
63
|
+
touchedSet.value = touched;
|
|
64
|
+
}
|
|
65
|
+
function notifyWatchers(name, value) {
|
|
66
|
+
fieldWatchers.get(name)?.forEach((cb) => cb(value));
|
|
67
|
+
if (globalWatchers.size > 0) {
|
|
68
|
+
const info = { name, type: "change" };
|
|
69
|
+
globalWatchers.forEach(
|
|
70
|
+
(cb) => cb(values, info)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function register(name) {
|
|
75
|
+
const key = String(name);
|
|
76
|
+
return {
|
|
77
|
+
name: key,
|
|
78
|
+
modelValue: values[key],
|
|
79
|
+
"onUpdate:modelValue": (value) => {
|
|
80
|
+
values[key] = value;
|
|
81
|
+
markDirty(key, value);
|
|
82
|
+
notifyWatchers(key, value);
|
|
83
|
+
if (mode === "onChange" || isSubmitted.value && reValidateMode === "onChange") {
|
|
84
|
+
errors.value = runValidation();
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
onBlur: () => {
|
|
88
|
+
markTouched(key);
|
|
89
|
+
if (mode === "onBlur" || isSubmitted.value && reValidateMode === "onBlur") {
|
|
90
|
+
errors.value = runValidation();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async function runSubmit(fn) {
|
|
96
|
+
isSubmitted.value = true;
|
|
97
|
+
submitCount.value += 1;
|
|
98
|
+
const validationErrors = runValidation();
|
|
99
|
+
if (Object.keys(validationErrors).length > 0) {
|
|
100
|
+
errors.value = validationErrors;
|
|
101
|
+
isSubmitSuccessful.value = false;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
errors.value = {};
|
|
105
|
+
isSubmitting.value = true;
|
|
106
|
+
try {
|
|
107
|
+
const typed = (0, import_form.transformValues)(schema, values);
|
|
108
|
+
await fn(typed);
|
|
109
|
+
isSubmitSuccessful.value = true;
|
|
110
|
+
} catch {
|
|
111
|
+
isSubmitSuccessful.value = false;
|
|
112
|
+
throw new Error("Submit handler failed");
|
|
113
|
+
} finally {
|
|
114
|
+
isSubmitting.value = false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function handleSubmit(fn) {
|
|
118
|
+
return async (e) => {
|
|
119
|
+
e.preventDefault?.();
|
|
120
|
+
await runSubmit(fn);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function setErrors(input) {
|
|
124
|
+
const normalized = {};
|
|
125
|
+
for (const [k, v] of Object.entries(input)) {
|
|
126
|
+
if (v !== void 0) normalized[k] = (0, import_form.normalizeError)(v);
|
|
127
|
+
}
|
|
128
|
+
errors.value = { ...errors.value, ...normalized };
|
|
129
|
+
}
|
|
130
|
+
function clearErrors(fields) {
|
|
131
|
+
if (!fields) {
|
|
132
|
+
errors.value = {};
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const next = { ...errors.value };
|
|
136
|
+
for (const f of fields) delete next[f];
|
|
137
|
+
errors.value = next;
|
|
138
|
+
}
|
|
139
|
+
function setValue(name, value) {
|
|
140
|
+
const key = String(name);
|
|
141
|
+
values[key] = value;
|
|
142
|
+
markDirty(key, value);
|
|
143
|
+
notifyWatchers(key, value);
|
|
144
|
+
}
|
|
145
|
+
function getValue(name) {
|
|
146
|
+
return values[String(name)];
|
|
147
|
+
}
|
|
148
|
+
function getValues() {
|
|
149
|
+
return { ...values };
|
|
150
|
+
}
|
|
151
|
+
function trigger(name) {
|
|
152
|
+
const validationErrors = runValidation();
|
|
153
|
+
const fieldsToValidate = name === void 0 ? Object.keys(schema) : Array.isArray(name) ? name : [name];
|
|
154
|
+
let allValid = true;
|
|
155
|
+
const next = { ...errors.value };
|
|
156
|
+
let changed = false;
|
|
157
|
+
for (const f of fieldsToValidate) {
|
|
158
|
+
const err = validationErrors[f];
|
|
159
|
+
if (err) {
|
|
160
|
+
allValid = false;
|
|
161
|
+
const prev = next[f];
|
|
162
|
+
if (!prev || prev.message !== err.message || prev.type !== err.type || prev.code !== err.code) {
|
|
163
|
+
next[f] = err;
|
|
164
|
+
changed = true;
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
if (f in next) {
|
|
168
|
+
delete next[f];
|
|
169
|
+
changed = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (changed) errors.value = next;
|
|
174
|
+
return allValid;
|
|
175
|
+
}
|
|
176
|
+
function reset(overrides) {
|
|
177
|
+
const next = overrides ?? defaultValues;
|
|
178
|
+
for (const k of Object.keys(values)) values[k] = next[k] ?? void 0;
|
|
179
|
+
errors.value = {};
|
|
180
|
+
isSubmitted.value = false;
|
|
181
|
+
isSubmitSuccessful.value = false;
|
|
182
|
+
submitCount.value = 0;
|
|
183
|
+
dirtySet.value = /* @__PURE__ */ new Set();
|
|
184
|
+
touchedSet.value = /* @__PURE__ */ new Set();
|
|
185
|
+
}
|
|
186
|
+
function watch(arg, legacyCb) {
|
|
187
|
+
if (legacyCb !== void 0) {
|
|
188
|
+
if (typeof arg !== "string" && !Array.isArray(arg)) {
|
|
189
|
+
throw new TypeError("watch(name, cb): name must be a field name string");
|
|
190
|
+
}
|
|
191
|
+
const name = String(arg);
|
|
192
|
+
let bucket = fieldWatchers.get(name);
|
|
193
|
+
if (!bucket) {
|
|
194
|
+
bucket = /* @__PURE__ */ new Set();
|
|
195
|
+
fieldWatchers.set(name, bucket);
|
|
196
|
+
}
|
|
197
|
+
bucket.add(legacyCb);
|
|
198
|
+
return () => bucket.delete(legacyCb);
|
|
199
|
+
}
|
|
200
|
+
if (typeof arg === "function") {
|
|
201
|
+
const cb = arg;
|
|
202
|
+
globalWatchers.add(cb);
|
|
203
|
+
return () => {
|
|
204
|
+
globalWatchers.delete(cb);
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (Array.isArray(arg)) {
|
|
208
|
+
return arg.map((n) => values[String(n)]);
|
|
209
|
+
}
|
|
210
|
+
if (typeof arg === "string") {
|
|
211
|
+
return values[arg];
|
|
212
|
+
}
|
|
213
|
+
return { ...values };
|
|
214
|
+
}
|
|
215
|
+
const control = (0, import_vue.reactive)({
|
|
216
|
+
_values: values,
|
|
217
|
+
get _errors() {
|
|
218
|
+
return errors.value;
|
|
219
|
+
},
|
|
220
|
+
get _touchedFields() {
|
|
221
|
+
return touchedSet.value;
|
|
222
|
+
},
|
|
223
|
+
_updateField: (name, value) => {
|
|
224
|
+
const key = String(name);
|
|
225
|
+
values[key] = value;
|
|
226
|
+
markDirty(key, value);
|
|
227
|
+
notifyWatchers(key, value);
|
|
228
|
+
},
|
|
229
|
+
_touchField: (name) => {
|
|
230
|
+
markTouched(String(name));
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
const dirtyFieldsRecord = (0, import_vue.computed)(
|
|
234
|
+
() => setToRecord(dirtySet.value)
|
|
235
|
+
);
|
|
236
|
+
const touchedFieldsRecord = (0, import_vue.computed)(
|
|
237
|
+
() => setToRecord(touchedSet.value)
|
|
238
|
+
);
|
|
239
|
+
const isDirtyComputed = (0, import_vue.computed)(() => dirtySet.value.size > 0);
|
|
240
|
+
const isValidComputed = (0, import_vue.computed)(() => Object.keys(errors.value).length === 0);
|
|
241
|
+
const formState = (0, import_vue.reactive)({
|
|
242
|
+
get errors() {
|
|
243
|
+
return errors.value;
|
|
244
|
+
},
|
|
245
|
+
get isSubmitting() {
|
|
246
|
+
return isSubmitting.value;
|
|
247
|
+
},
|
|
248
|
+
get isSubmitted() {
|
|
249
|
+
return isSubmitted.value;
|
|
250
|
+
},
|
|
251
|
+
get isSubmitSuccessful() {
|
|
252
|
+
return isSubmitSuccessful.value;
|
|
253
|
+
},
|
|
254
|
+
get submitCount() {
|
|
255
|
+
return submitCount.value;
|
|
256
|
+
},
|
|
257
|
+
get isDirty() {
|
|
258
|
+
return isDirtyComputed.value;
|
|
259
|
+
},
|
|
260
|
+
get isValid() {
|
|
261
|
+
return isValidComputed.value;
|
|
262
|
+
},
|
|
263
|
+
get dirtyFields() {
|
|
264
|
+
return dirtyFieldsRecord.value;
|
|
265
|
+
},
|
|
266
|
+
get touchedFields() {
|
|
267
|
+
return touchedFieldsRecord.value;
|
|
268
|
+
},
|
|
269
|
+
get defaultValues() {
|
|
270
|
+
return { ...defaultValues };
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
return {
|
|
274
|
+
formState,
|
|
275
|
+
control,
|
|
276
|
+
register,
|
|
277
|
+
handleSubmit,
|
|
278
|
+
setErrors,
|
|
279
|
+
clearErrors,
|
|
280
|
+
setValue,
|
|
281
|
+
trigger,
|
|
282
|
+
getValue,
|
|
283
|
+
getValues,
|
|
284
|
+
reset,
|
|
285
|
+
watch
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
289
|
+
0 && (module.exports = {
|
|
290
|
+
useValfuseForm
|
|
291
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// src/composables/use-valfuse-form.ts
|
|
2
|
+
import { computed, reactive, ref, shallowRef } from "vue";
|
|
3
|
+
import { validateSchema, transformValues, normalizeError } from "@valfuse-node/form";
|
|
4
|
+
function useValfuseForm(options) {
|
|
5
|
+
const { schema, defaultValues, mode = "onSubmit", reValidateMode = "onChange" } = options;
|
|
6
|
+
const values = reactive({ ...defaultValues });
|
|
7
|
+
const errors = ref({});
|
|
8
|
+
const isSubmitting = ref(false);
|
|
9
|
+
const isSubmitted = ref(false);
|
|
10
|
+
const isSubmitSuccessful = ref(false);
|
|
11
|
+
const submitCount = ref(0);
|
|
12
|
+
const dirtySet = shallowRef(/* @__PURE__ */ new Set());
|
|
13
|
+
const touchedSet = shallowRef(/* @__PURE__ */ new Set());
|
|
14
|
+
const fieldWatchers = /* @__PURE__ */ new Map();
|
|
15
|
+
const globalWatchers = /* @__PURE__ */ new Set();
|
|
16
|
+
function runValidation() {
|
|
17
|
+
const typed = transformValues(schema, values);
|
|
18
|
+
return validateSchema(schema, typed);
|
|
19
|
+
}
|
|
20
|
+
function setToRecord(set) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const k of set) out[k] = true;
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
function markDirty(key, newValue) {
|
|
26
|
+
const dirty = new Set(dirtySet.value);
|
|
27
|
+
if (newValue !== defaultValues[key]) {
|
|
28
|
+
dirty.add(key);
|
|
29
|
+
} else {
|
|
30
|
+
dirty.delete(key);
|
|
31
|
+
}
|
|
32
|
+
dirtySet.value = dirty;
|
|
33
|
+
}
|
|
34
|
+
function markTouched(key) {
|
|
35
|
+
const touched = new Set(touchedSet.value);
|
|
36
|
+
touched.add(key);
|
|
37
|
+
touchedSet.value = touched;
|
|
38
|
+
}
|
|
39
|
+
function notifyWatchers(name, value) {
|
|
40
|
+
fieldWatchers.get(name)?.forEach((cb) => cb(value));
|
|
41
|
+
if (globalWatchers.size > 0) {
|
|
42
|
+
const info = { name, type: "change" };
|
|
43
|
+
globalWatchers.forEach(
|
|
44
|
+
(cb) => cb(values, info)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function register(name) {
|
|
49
|
+
const key = String(name);
|
|
50
|
+
return {
|
|
51
|
+
name: key,
|
|
52
|
+
modelValue: values[key],
|
|
53
|
+
"onUpdate:modelValue": (value) => {
|
|
54
|
+
values[key] = value;
|
|
55
|
+
markDirty(key, value);
|
|
56
|
+
notifyWatchers(key, value);
|
|
57
|
+
if (mode === "onChange" || isSubmitted.value && reValidateMode === "onChange") {
|
|
58
|
+
errors.value = runValidation();
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
onBlur: () => {
|
|
62
|
+
markTouched(key);
|
|
63
|
+
if (mode === "onBlur" || isSubmitted.value && reValidateMode === "onBlur") {
|
|
64
|
+
errors.value = runValidation();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async function runSubmit(fn) {
|
|
70
|
+
isSubmitted.value = true;
|
|
71
|
+
submitCount.value += 1;
|
|
72
|
+
const validationErrors = runValidation();
|
|
73
|
+
if (Object.keys(validationErrors).length > 0) {
|
|
74
|
+
errors.value = validationErrors;
|
|
75
|
+
isSubmitSuccessful.value = false;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
errors.value = {};
|
|
79
|
+
isSubmitting.value = true;
|
|
80
|
+
try {
|
|
81
|
+
const typed = transformValues(schema, values);
|
|
82
|
+
await fn(typed);
|
|
83
|
+
isSubmitSuccessful.value = true;
|
|
84
|
+
} catch {
|
|
85
|
+
isSubmitSuccessful.value = false;
|
|
86
|
+
throw new Error("Submit handler failed");
|
|
87
|
+
} finally {
|
|
88
|
+
isSubmitting.value = false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function handleSubmit(fn) {
|
|
92
|
+
return async (e) => {
|
|
93
|
+
e.preventDefault?.();
|
|
94
|
+
await runSubmit(fn);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function setErrors(input) {
|
|
98
|
+
const normalized = {};
|
|
99
|
+
for (const [k, v] of Object.entries(input)) {
|
|
100
|
+
if (v !== void 0) normalized[k] = normalizeError(v);
|
|
101
|
+
}
|
|
102
|
+
errors.value = { ...errors.value, ...normalized };
|
|
103
|
+
}
|
|
104
|
+
function clearErrors(fields) {
|
|
105
|
+
if (!fields) {
|
|
106
|
+
errors.value = {};
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const next = { ...errors.value };
|
|
110
|
+
for (const f of fields) delete next[f];
|
|
111
|
+
errors.value = next;
|
|
112
|
+
}
|
|
113
|
+
function setValue(name, value) {
|
|
114
|
+
const key = String(name);
|
|
115
|
+
values[key] = value;
|
|
116
|
+
markDirty(key, value);
|
|
117
|
+
notifyWatchers(key, value);
|
|
118
|
+
}
|
|
119
|
+
function getValue(name) {
|
|
120
|
+
return values[String(name)];
|
|
121
|
+
}
|
|
122
|
+
function getValues() {
|
|
123
|
+
return { ...values };
|
|
124
|
+
}
|
|
125
|
+
function trigger(name) {
|
|
126
|
+
const validationErrors = runValidation();
|
|
127
|
+
const fieldsToValidate = name === void 0 ? Object.keys(schema) : Array.isArray(name) ? name : [name];
|
|
128
|
+
let allValid = true;
|
|
129
|
+
const next = { ...errors.value };
|
|
130
|
+
let changed = false;
|
|
131
|
+
for (const f of fieldsToValidate) {
|
|
132
|
+
const err = validationErrors[f];
|
|
133
|
+
if (err) {
|
|
134
|
+
allValid = false;
|
|
135
|
+
const prev = next[f];
|
|
136
|
+
if (!prev || prev.message !== err.message || prev.type !== err.type || prev.code !== err.code) {
|
|
137
|
+
next[f] = err;
|
|
138
|
+
changed = true;
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
if (f in next) {
|
|
142
|
+
delete next[f];
|
|
143
|
+
changed = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (changed) errors.value = next;
|
|
148
|
+
return allValid;
|
|
149
|
+
}
|
|
150
|
+
function reset(overrides) {
|
|
151
|
+
const next = overrides ?? defaultValues;
|
|
152
|
+
for (const k of Object.keys(values)) values[k] = next[k] ?? void 0;
|
|
153
|
+
errors.value = {};
|
|
154
|
+
isSubmitted.value = false;
|
|
155
|
+
isSubmitSuccessful.value = false;
|
|
156
|
+
submitCount.value = 0;
|
|
157
|
+
dirtySet.value = /* @__PURE__ */ new Set();
|
|
158
|
+
touchedSet.value = /* @__PURE__ */ new Set();
|
|
159
|
+
}
|
|
160
|
+
function watch(arg, legacyCb) {
|
|
161
|
+
if (legacyCb !== void 0) {
|
|
162
|
+
if (typeof arg !== "string" && !Array.isArray(arg)) {
|
|
163
|
+
throw new TypeError("watch(name, cb): name must be a field name string");
|
|
164
|
+
}
|
|
165
|
+
const name = String(arg);
|
|
166
|
+
let bucket = fieldWatchers.get(name);
|
|
167
|
+
if (!bucket) {
|
|
168
|
+
bucket = /* @__PURE__ */ new Set();
|
|
169
|
+
fieldWatchers.set(name, bucket);
|
|
170
|
+
}
|
|
171
|
+
bucket.add(legacyCb);
|
|
172
|
+
return () => bucket.delete(legacyCb);
|
|
173
|
+
}
|
|
174
|
+
if (typeof arg === "function") {
|
|
175
|
+
const cb = arg;
|
|
176
|
+
globalWatchers.add(cb);
|
|
177
|
+
return () => {
|
|
178
|
+
globalWatchers.delete(cb);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(arg)) {
|
|
182
|
+
return arg.map((n) => values[String(n)]);
|
|
183
|
+
}
|
|
184
|
+
if (typeof arg === "string") {
|
|
185
|
+
return values[arg];
|
|
186
|
+
}
|
|
187
|
+
return { ...values };
|
|
188
|
+
}
|
|
189
|
+
const control = reactive({
|
|
190
|
+
_values: values,
|
|
191
|
+
get _errors() {
|
|
192
|
+
return errors.value;
|
|
193
|
+
},
|
|
194
|
+
get _touchedFields() {
|
|
195
|
+
return touchedSet.value;
|
|
196
|
+
},
|
|
197
|
+
_updateField: (name, value) => {
|
|
198
|
+
const key = String(name);
|
|
199
|
+
values[key] = value;
|
|
200
|
+
markDirty(key, value);
|
|
201
|
+
notifyWatchers(key, value);
|
|
202
|
+
},
|
|
203
|
+
_touchField: (name) => {
|
|
204
|
+
markTouched(String(name));
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
const dirtyFieldsRecord = computed(
|
|
208
|
+
() => setToRecord(dirtySet.value)
|
|
209
|
+
);
|
|
210
|
+
const touchedFieldsRecord = computed(
|
|
211
|
+
() => setToRecord(touchedSet.value)
|
|
212
|
+
);
|
|
213
|
+
const isDirtyComputed = computed(() => dirtySet.value.size > 0);
|
|
214
|
+
const isValidComputed = computed(() => Object.keys(errors.value).length === 0);
|
|
215
|
+
const formState = reactive({
|
|
216
|
+
get errors() {
|
|
217
|
+
return errors.value;
|
|
218
|
+
},
|
|
219
|
+
get isSubmitting() {
|
|
220
|
+
return isSubmitting.value;
|
|
221
|
+
},
|
|
222
|
+
get isSubmitted() {
|
|
223
|
+
return isSubmitted.value;
|
|
224
|
+
},
|
|
225
|
+
get isSubmitSuccessful() {
|
|
226
|
+
return isSubmitSuccessful.value;
|
|
227
|
+
},
|
|
228
|
+
get submitCount() {
|
|
229
|
+
return submitCount.value;
|
|
230
|
+
},
|
|
231
|
+
get isDirty() {
|
|
232
|
+
return isDirtyComputed.value;
|
|
233
|
+
},
|
|
234
|
+
get isValid() {
|
|
235
|
+
return isValidComputed.value;
|
|
236
|
+
},
|
|
237
|
+
get dirtyFields() {
|
|
238
|
+
return dirtyFieldsRecord.value;
|
|
239
|
+
},
|
|
240
|
+
get touchedFields() {
|
|
241
|
+
return touchedFieldsRecord.value;
|
|
242
|
+
},
|
|
243
|
+
get defaultValues() {
|
|
244
|
+
return { ...defaultValues };
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
return {
|
|
248
|
+
formState,
|
|
249
|
+
control,
|
|
250
|
+
register,
|
|
251
|
+
handleSubmit,
|
|
252
|
+
setErrors,
|
|
253
|
+
clearErrors,
|
|
254
|
+
setValue,
|
|
255
|
+
trigger,
|
|
256
|
+
getValue,
|
|
257
|
+
getValues,
|
|
258
|
+
reset,
|
|
259
|
+
watch
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
export {
|
|
263
|
+
useValfuseForm
|
|
264
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@valfuse-node/vue",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Vue adapter for valfuse-node — useValfuseForm composable",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"keywords": ["validation", "schema", "form", "vue", "vue3", "composable", "typescript", "valfuse"],
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.mjs",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.mjs",
|
|
15
|
+
"require": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean --external vue --external @valfuse-node/form",
|
|
21
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch --external vue --external @valfuse-node/form",
|
|
22
|
+
"lint": "eslint src",
|
|
23
|
+
"test": "vitest run --passWithNoTests",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"clean": "rm -rf dist coverage"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@valfuse-node/form": "*"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"vue": ">=3"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": { "access": "public" },
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@vitejs/plugin-vue": "latest",
|
|
36
|
+
"@vitest/coverage-v8": "latest",
|
|
37
|
+
"@vue/test-utils": "latest",
|
|
38
|
+
"vitest": "latest",
|
|
39
|
+
"vue": "^3"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|