@valfuse-node/react 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 +620 -0
- package/dist/index.d.mts +446 -0
- package/dist/index.d.ts +446 -0
- package/dist/index.js +986 -0
- package/dist/index.mjs +957 -0
- package/package.json +48 -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,620 @@
|
|
|
1
|
+
# @valfuse-node/react
|
|
2
|
+
|
|
3
|
+
> React 18+ adapter for `@valfuse-node` — `useValfuseForm` hook, `<ValfuseController>`, full localization runtime (provider, hooks, storage strategies, SSR helpers). No external form library dependency.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @valfuse-node/react @valfuse-node/core
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
**Peer dependencies:** `react >= 18`, `react-dom >= 18`
|
|
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
|
+
- [`ValfuseController`](#valfusecontroller)
|
|
24
|
+
- [Localization Runtime](#localization-runtime)
|
|
25
|
+
- [`LocalizationProvider`](#localizationprovider)
|
|
26
|
+
- [`useLocalization(options?)` — full localizer API](#uselocalizationoptions--full-localizer-api)
|
|
27
|
+
- [`useLocalizationTree()`](#uselocalizationtree)
|
|
28
|
+
- [Storage strategies](#storage-strategies)
|
|
29
|
+
- [`createLocalizationStore`](#createlocalizationstore)
|
|
30
|
+
- [`createLazyLocaleLoader`](#createlazyloacaleloader)
|
|
31
|
+
- [`createSsrLocalizationState`](#createssrlocalizationstate)
|
|
32
|
+
- [Type Reference](#type-reference)
|
|
33
|
+
- [Development Usage](#development-usage)
|
|
34
|
+
- [License](#license)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { createSchema } from "@valfuse-node/core";
|
|
42
|
+
import { useValfuseForm } from "@valfuse-node/react";
|
|
43
|
+
|
|
44
|
+
const schema = createSchema({
|
|
45
|
+
email: { type: "string", rules: [{ name: "required", error: { message: "Required" } }, { name: "email", error: { message: "Invalid" } }] },
|
|
46
|
+
password: { type: "string", rules: [{ name: "required", error: { message: "Required" } }, { name: "minLength", value: 8, error: { message: "Min 8 chars" } }] },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export function LoginForm() {
|
|
50
|
+
const form = useValfuseForm({ schema, defaultValues: { email: "", password: "" } });
|
|
51
|
+
|
|
52
|
+
const handleSubmit = form.handleSubmit(async (values) => {
|
|
53
|
+
await loginApi(values);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<form onSubmit={handleSubmit}>
|
|
58
|
+
<input {...form.register("email")} />
|
|
59
|
+
{form.formState.errors.email && <span>{form.formState.errors.email.message}</span>}
|
|
60
|
+
|
|
61
|
+
<input type="password" {...form.register("password")} />
|
|
62
|
+
{form.formState.errors.password && <span>{form.formState.errors.password.message}</span>}
|
|
63
|
+
|
|
64
|
+
<button type="submit" disabled={form.formState.isSubmitting}>Log in</button>
|
|
65
|
+
</form>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## `useValfuseForm(options)`
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
function useValfuseForm<TFieldValues extends Record<string, unknown>>(
|
|
76
|
+
props: UseValfuseFormProps<TFieldValues>
|
|
77
|
+
): UseValfuseFormReturn<TFieldValues>;
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Options
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
interface UseValfuseFormProps<TFieldValues> {
|
|
84
|
+
schema: ValfuseSchema; // required — from @valfuse-node/form
|
|
85
|
+
defaultValues: TFieldValues; // required — values shape (inferred)
|
|
86
|
+
mode?: "onSubmit" | "onChange" | "onBlur"; // default: "onSubmit"
|
|
87
|
+
reValidateMode?: "onChange" | "onBlur" | "onSubmit"; // default: "onChange"
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
| Option | Type | Default | Notes |
|
|
92
|
+
|---|---|---|---|
|
|
93
|
+
| `schema` | `ValfuseSchema` | — (required) | The rule-based schema |
|
|
94
|
+
| `defaultValues` | object literal | — (required) | The generic `TFieldValues` is **inferred** from this. The same shape flows through `form.handleSubmit(fn)`, `formState.errors`, etc. |
|
|
95
|
+
| `mode` | union | `"onSubmit"` | When validation first runs. `"onChange"` validates on every keystroke; `"onBlur"` validates when a field loses focus |
|
|
96
|
+
| `reValidateMode` | union | `"onChange"` | Mode used **after the first submit attempt** to re-validate fields the user fixes |
|
|
97
|
+
|
|
98
|
+
### Return value
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
interface UseValfuseFormReturn<TFieldValues> {
|
|
102
|
+
register: (name) => { name, value, onChange, onBlur, ref };
|
|
103
|
+
control: ValfuseFormControl<TFieldValues>;
|
|
104
|
+
formState: ValfuseFormState<TFieldValues>;
|
|
105
|
+
handleSubmit: (onValid) => (e?) => Promise<void>;
|
|
106
|
+
setErrors: (errors) => void;
|
|
107
|
+
clearErrors: (fields?) => void;
|
|
108
|
+
setValue: (name, value, options?) => void;
|
|
109
|
+
trigger: (name?) => boolean;
|
|
110
|
+
watch: ValfuseWatchFunction<TFieldValues>;
|
|
111
|
+
reset: (values?) => void;
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `form.register(name)`
|
|
116
|
+
|
|
117
|
+
Returns props to spread onto an `<input>`:
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
<input {...form.register("email")} />
|
|
121
|
+
// expands to: name="email" value={...} onChange={...} onBlur={...}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### `form.formState`
|
|
125
|
+
|
|
126
|
+
| Field | Type | Description |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `errors` | `Partial<Record<keyof T, ValfuseFieldError>>` | Current field errors (validation + server + manual) |
|
|
129
|
+
| `isSubmitting` | `boolean` | `true` while the async submit handler is running |
|
|
130
|
+
| `isSubmitted` | `boolean` | `true` after the first submit attempt |
|
|
131
|
+
| `isSubmitSuccessful` | `boolean` | `true` if the most recent submit completed without throwing |
|
|
132
|
+
| `submitCount` | `number` | Total submit attempts |
|
|
133
|
+
| `isDirty` | `boolean` | `true` if any field differs from `defaultValues` |
|
|
134
|
+
| `isValid` | `boolean` | `true` when no errors are present |
|
|
135
|
+
| `dirtyFields` | `Partial<Record<keyof T, true>>` | Fields that differ from `defaultValues` |
|
|
136
|
+
| `touchedFields` | `Partial<Record<keyof T, true>>` | Fields the user has blurred |
|
|
137
|
+
| `defaultValues` | `Readonly<T>` | The defaults passed at hook initialization |
|
|
138
|
+
|
|
139
|
+
### `form.handleSubmit(onValid)`
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
const onSubmit = form.handleSubmit(async (values) => {
|
|
143
|
+
// values: TFieldValues (already transformed + validated)
|
|
144
|
+
await api.save(values);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return <form onSubmit={onSubmit}>…</form>;
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Validates first; calls `onValid(values)` only when validation passes. Sets `isSubmitting = true` for the duration of the (possibly async) handler.
|
|
151
|
+
|
|
152
|
+
### `form.setErrors(errors)` / `form.clearErrors(fields?)`
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
form.setErrors({ email: { message: "Account exists", code: "auth.duplicate" } });
|
|
156
|
+
form.clearErrors(); // clear all
|
|
157
|
+
form.clearErrors("email"); // clear one
|
|
158
|
+
form.clearErrors(["email", "password"]); // clear many
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### `form.setValue(name, value, options?)`
|
|
162
|
+
|
|
163
|
+
Programmatically set a field value:
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
form.setValue("email", "alice@example.com");
|
|
167
|
+
form.setValue("email", "alice@example.com", { shouldValidate: true });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
By default, setting a value **does not** trigger validation. Pass `{ shouldValidate: true }` to run validation immediately.
|
|
171
|
+
|
|
172
|
+
### `form.trigger(name?)`
|
|
173
|
+
|
|
174
|
+
Manually trigger validation:
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
form.trigger(); // all fields
|
|
178
|
+
form.trigger("email"); // one field
|
|
179
|
+
form.trigger(["email", "password"]); // many
|
|
180
|
+
// returns boolean — true if all triggered fields are valid
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### `form.watch(...)` — multi-overload
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
const all = form.watch(); // TFieldValues snapshot
|
|
187
|
+
const email = form.watch("email"); // TFieldValues["email"]
|
|
188
|
+
const pair = form.watch(["email", "name"]); // Array of values
|
|
189
|
+
|
|
190
|
+
const unsub = form.watch((values, info) => { // subscribe
|
|
191
|
+
console.log("changed:", info?.name, values);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// later
|
|
195
|
+
unsub();
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### `form.reset(values?)`
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
form.reset(); // back to defaultValues
|
|
202
|
+
form.reset({ email: "" }); // partial override
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Resets values, errors, touched, dirty, and submission state.
|
|
206
|
+
|
|
207
|
+
### `form.control`
|
|
208
|
+
|
|
209
|
+
Opaque control object — pass it to `<ValfuseController>` for custom inputs.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## `ValfuseController`
|
|
214
|
+
|
|
215
|
+
For controlled inputs that don't work with `register` (custom select, date picker, checkbox group, etc.).
|
|
216
|
+
|
|
217
|
+
```tsx
|
|
218
|
+
import { useValfuseForm, ValfuseController } from "@valfuse-node/react";
|
|
219
|
+
|
|
220
|
+
const form = useValfuseForm({ schema, defaultValues: { role: "" } });
|
|
221
|
+
|
|
222
|
+
<ValfuseController
|
|
223
|
+
control={form.control}
|
|
224
|
+
name="role"
|
|
225
|
+
render={({ field, fieldState }) => (
|
|
226
|
+
<RoleSelect
|
|
227
|
+
value={field.value}
|
|
228
|
+
onChange={field.onChange}
|
|
229
|
+
onBlur={field.onBlur}
|
|
230
|
+
error={fieldState.error?.message}
|
|
231
|
+
/>
|
|
232
|
+
)}
|
|
233
|
+
/>;
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Render-prop shape
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
interface ValfuseControllerRenderProps<T, TName extends keyof T & string> {
|
|
240
|
+
field: {
|
|
241
|
+
name: string;
|
|
242
|
+
value: T[TName];
|
|
243
|
+
onChange: (value: T[TName]) => void; // receives raw value, not a DOM event
|
|
244
|
+
onBlur: () => void;
|
|
245
|
+
};
|
|
246
|
+
fieldState: {
|
|
247
|
+
error?: ValfuseFieldError;
|
|
248
|
+
isTouched: boolean;
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
> **Stable references:** the `field` and `fieldState` objects are memoized per-name. Unrelated field updates do not invalidate the props passed to the render child — `React.memo`'d inputs won't re-render unnecessarily.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Localization Runtime
|
|
258
|
+
|
|
259
|
+
### `LocalizationProvider`
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
import { LocalizationProvider, localStorageStrategy } from "@valfuse-node/react";
|
|
263
|
+
import localization from "./assets/localizations/localization.manifest.json";
|
|
264
|
+
|
|
265
|
+
<LocalizationProvider
|
|
266
|
+
manifest={localization}
|
|
267
|
+
storage={localStorageStrategy({ key: "app-locale" })}
|
|
268
|
+
initialLocale="en"
|
|
269
|
+
>
|
|
270
|
+
<App />
|
|
271
|
+
</LocalizationProvider>
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
| Prop | Type | Description |
|
|
275
|
+
|---|---|---|
|
|
276
|
+
| `manifest` | `RuntimeManifest` | The generated manifest JSON |
|
|
277
|
+
| `initialLocale` | `string` | Locale to use when no value is stored. Must be in `manifest.locales` |
|
|
278
|
+
| `storage` | `LocaleStorage` | Pluggable persistence (defaults to no persistence — in-memory only) |
|
|
279
|
+
|
|
280
|
+
The provider resolves the initial locale in this order: **storage value** → `initialLocale` → `manifest.base_locale`.
|
|
281
|
+
|
|
282
|
+
### `useLocalization(options?)` — full localizer API
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
import { useLocalization } from "@valfuse-node/react";
|
|
286
|
+
|
|
287
|
+
const {
|
|
288
|
+
// Translation methods
|
|
289
|
+
translate, translateOrNull,
|
|
290
|
+
format, formatOrNull,
|
|
291
|
+
plural, pluralOrNull,
|
|
292
|
+
gender, context,
|
|
293
|
+
namespace,
|
|
294
|
+
|
|
295
|
+
// Iteration
|
|
296
|
+
entriesForLocale,
|
|
297
|
+
|
|
298
|
+
// Provider context
|
|
299
|
+
locale, setLocale,
|
|
300
|
+
store, manifest,
|
|
301
|
+
} = useLocalization();
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
#### `translate(key, fallback?)`
|
|
305
|
+
|
|
306
|
+
```tsx
|
|
307
|
+
translate("common.welcome"); // → "Hello!"
|
|
308
|
+
translate("common.missing", "Default greeting"); // → "Default greeting"
|
|
309
|
+
translate("common.missing", null); // → key (returns key when missing & no fallback)
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
#### `translateOrNull(key)` — null-safe variant
|
|
313
|
+
|
|
314
|
+
```tsx
|
|
315
|
+
translateOrNull("common.welcome"); // → "Hello!"
|
|
316
|
+
translateOrNull("common.missing"); // → null
|
|
317
|
+
translateOrNull(null); // → null
|
|
318
|
+
translateOrNull(undefined); // → null
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
#### `format(key, params)` / `formatOrNull(key, params)`
|
|
322
|
+
|
|
323
|
+
```tsx
|
|
324
|
+
format("common.welcome", { name: "Alfin" }); // → "Hello, Alfin!"
|
|
325
|
+
formatOrNull("common.welcome", { name: "Alfin" }); // → "Hello, Alfin!" or null
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
#### `plural(key, count)` / `pluralOrNull(key, count)`
|
|
329
|
+
|
|
330
|
+
```tsx
|
|
331
|
+
plural("common.items.count", 0); // → "No items"
|
|
332
|
+
plural("common.items.count", 1); // → "1 item"
|
|
333
|
+
plural("common.items.count", 5); // → "5 items"
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
#### `gender(key, value, params)`
|
|
337
|
+
|
|
338
|
+
```tsx
|
|
339
|
+
gender("common.profile.followers", "male", { count: 3 }); // → "3 male followers"
|
|
340
|
+
gender("common.profile.followers", "female", { count: 3 }); // → "3 female followers"
|
|
341
|
+
gender("common.profile.followers", "other", { count: 3 }); // → "3 followers"
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
#### `context(key, value, params?)`
|
|
345
|
+
|
|
346
|
+
```tsx
|
|
347
|
+
context("common.word.open", "verb"); // → "Open the file"
|
|
348
|
+
context("common.word.open", "adjective", {}); // → "The file is open"
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
#### `namespace(scope)` — scoped sub-localizer
|
|
352
|
+
|
|
353
|
+
```tsx
|
|
354
|
+
const t = useLocalization().namespace("common.errors");
|
|
355
|
+
t.translate("required"); // looks up "common.errors.required"
|
|
356
|
+
t.format("min_length", { min: 8 });
|
|
357
|
+
t.plural("items.count", 5);
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
`namespace(scope)` returns a `NamespacedLocalizer` with the same 8 methods (`translate`, `translateOrNull`, `format`, `formatOrNull`, `plural`, `pluralOrNull`, `gender`, `context`), all automatically prefixed.
|
|
361
|
+
|
|
362
|
+
#### `entriesForLocale`
|
|
363
|
+
|
|
364
|
+
```tsx
|
|
365
|
+
const entries = useLocalization().entriesForLocale;
|
|
366
|
+
// → Array<[key, value]> sorted alphabetically by key
|
|
367
|
+
// Useful for building a search interface over your translations.
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
#### `locale` / `setLocale(locale)`
|
|
371
|
+
|
|
372
|
+
```tsx
|
|
373
|
+
const { locale, setLocale } = useLocalization();
|
|
374
|
+
setLocale("id"); // switch to Indonesian
|
|
375
|
+
console.log(locale); // → "id"
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
`setLocale` also writes the new value to the configured `storage` (if any).
|
|
379
|
+
|
|
380
|
+
#### `store`
|
|
381
|
+
|
|
382
|
+
Lower-level mutable store for when you want to bypass the hook overhead:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
interface LocalizationStore {
|
|
386
|
+
getLocale(): string;
|
|
387
|
+
setLocale(locale: string): void;
|
|
388
|
+
t(key: string, params?: Record<string, string | number>): string;
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
`store.t("common.welcome", { name: "Alfin" })` does a single lookup + interpolation.
|
|
393
|
+
|
|
394
|
+
#### `manifest`
|
|
395
|
+
|
|
396
|
+
The raw `RuntimeManifest` object you passed to the provider. Useful for introspection, building custom selectors, etc.
|
|
397
|
+
|
|
398
|
+
### `useLocalizationTree()`
|
|
399
|
+
|
|
400
|
+
Returns a nested object built from the manifest, with placeholders turned into functions:
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
const { strings, placeholders } = useLocalizationTree();
|
|
404
|
+
|
|
405
|
+
// strings.app.title → "My App"
|
|
406
|
+
// strings.errors.required → "This field is required"
|
|
407
|
+
// placeholders.welcome({ name }) → "Hello, {name}".replace("{name}", name)
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Use it when you want a deeply-nested "namespace" shape (e.g. for I18n-style consumers) rather than flat keys.
|
|
411
|
+
|
|
412
|
+
### Storage strategies
|
|
413
|
+
|
|
414
|
+
| Strategy | Persistence | SSR-safe | Options |
|
|
415
|
+
|---|---|---|---|
|
|
416
|
+
| `localStorageStrategy()` | `window.localStorage` | partial | `{ key }` (default `"locale"`) |
|
|
417
|
+
| `sessionStorageStrategy()` | `window.sessionStorage` | partial | `{ key }` |
|
|
418
|
+
| `cookieStrategy({ … })` | HTTP cookie | yes | `{ key, domain, path, maxAge, secure, sameSite }` |
|
|
419
|
+
| `memoryStrategy()` | in-process memory | yes | `{ initialLocale }` |
|
|
420
|
+
| `composeStorage(a, b, …)` | union of multiple | mixed | — |
|
|
421
|
+
|
|
422
|
+
```tsx
|
|
423
|
+
// Persist in BOTH a cookie (for SSR) and localStorage (for the client)
|
|
424
|
+
const storage = composeStorage(
|
|
425
|
+
cookieStrategy({ domain: ".example.com" }),
|
|
426
|
+
localStorageStrategy({ key: "locale" }),
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
<LocalizationProvider manifest={manifest} storage={storage}>…</LocalizationProvider>
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### `createLocalizationStore`
|
|
433
|
+
|
|
434
|
+
Build a standalone, mutable store (useful outside React):
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
import { createLocalizationStore } from "@valfuse-node/react";
|
|
438
|
+
|
|
439
|
+
const store = createLocalizationStore(manifest, "id");
|
|
440
|
+
store.t("common.welcome", { name: "Alfin" }); // → "Halo, Alfin!"
|
|
441
|
+
store.setLocale("en");
|
|
442
|
+
store.getLocale(); // → "en"
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### `createLazyLocaleLoader`
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
import { createLazyLocaleLoader } from "@valfuse-node/react";
|
|
449
|
+
|
|
450
|
+
const loadLocale = createLazyLocaleLoader(manifest);
|
|
451
|
+
const enMessages = await loadLocale("en"); // → Record<string, string>
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
Useful for code-splitting per-locale payloads.
|
|
455
|
+
|
|
456
|
+
### `createSsrLocalizationState`
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
import { createSsrLocalizationState } from "@valfuse-node/react";
|
|
460
|
+
|
|
461
|
+
const ssrState = createSsrLocalizationState(manifest, "id");
|
|
462
|
+
// → { locale: "id", messages: { "common.welcome": "Halo, {name}!", … } }
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Pre-render localized content on the server and pass `messages` + `locale` into the client provider to avoid a flash of incorrect locale.
|
|
466
|
+
|
|
467
|
+
---
|
|
468
|
+
|
|
469
|
+
## Type Reference
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
import type {
|
|
473
|
+
UseValfuseFormProps,
|
|
474
|
+
UseValfuseFormReturn,
|
|
475
|
+
ValfuseFormMode,
|
|
476
|
+
ValfuseFormState,
|
|
477
|
+
ValfuseFormErrors,
|
|
478
|
+
ValfuseFormControl,
|
|
479
|
+
ValfuseFieldError,
|
|
480
|
+
ValfuseRegisterReturn,
|
|
481
|
+
ValfuseDirtyFields,
|
|
482
|
+
ValfuseTouchedFields,
|
|
483
|
+
ValfuseWatchCallback,
|
|
484
|
+
ValfuseWatchFunction,
|
|
485
|
+
ValfuseControllerProps,
|
|
486
|
+
ValfuseControllerField,
|
|
487
|
+
ValfuseControllerFieldState,
|
|
488
|
+
ValfuseControllerRenderProps,
|
|
489
|
+
LocalizationProviderProps,
|
|
490
|
+
LocalizationContextValue,
|
|
491
|
+
UseLocalizationOptions,
|
|
492
|
+
NamespacedLocalizer,
|
|
493
|
+
InterpolationParams,
|
|
494
|
+
GenderVariant,
|
|
495
|
+
TranslationFallback,
|
|
496
|
+
LocalizationStore,
|
|
497
|
+
LocaleStorage,
|
|
498
|
+
} from "@valfuse-node/react";
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
---
|
|
502
|
+
|
|
503
|
+
## Development Usage
|
|
504
|
+
|
|
505
|
+
### Set up the validation schema
|
|
506
|
+
|
|
507
|
+
The schema is shared with `@valfuse-node/form`. Define it once, reuse everywhere.
|
|
508
|
+
|
|
509
|
+
```ts
|
|
510
|
+
// schemas/user.ts
|
|
511
|
+
import { createSchema } from "@valfuse-node/form";
|
|
512
|
+
|
|
513
|
+
export const userSchema = createSchema({
|
|
514
|
+
name: { type: "string", rules: [{ name: "required", error: { message: "Required" } }] },
|
|
515
|
+
email: { type: "string", rules: [{ name: "required", error: { message: "Required" } }, { name: "email", error: { message: "Invalid" } }] },
|
|
516
|
+
age: { type: "number", rules: [{ name: "min", value: 18, error: { message: "18+" } }] },
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
export type UserValues = {
|
|
520
|
+
name: string;
|
|
521
|
+
email: string;
|
|
522
|
+
age: number;
|
|
523
|
+
};
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### Build a form with all features
|
|
527
|
+
|
|
528
|
+
```tsx
|
|
529
|
+
import { useReactValfuseForm, ValfuseController } from "@valfuse-node/core";
|
|
530
|
+
import { userSchema, type UserValues } from "./schemas/user";
|
|
531
|
+
|
|
532
|
+
export function UserForm() {
|
|
533
|
+
const form = useReactValfuseForm<UserValues>({
|
|
534
|
+
schema: userSchema,
|
|
535
|
+
defaultValues: { name: "", email: "", age: 0 },
|
|
536
|
+
mode: "onBlur",
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const onSubmit = form.handleSubmit(async (values) => {
|
|
540
|
+
try {
|
|
541
|
+
await api.updateUser(values);
|
|
542
|
+
} catch (err) {
|
|
543
|
+
form.setErrors({
|
|
544
|
+
email: { message: "Email already in use", code: "auth.duplicate", type: "server" },
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
return (
|
|
550
|
+
<form onSubmit={onSubmit}>
|
|
551
|
+
<input {...form.register("name")} placeholder="Name" />
|
|
552
|
+
{form.formState.errors.name && <span>{form.formState.errors.name.message}</span>}
|
|
553
|
+
|
|
554
|
+
<input {...form.register("email")} placeholder="Email" />
|
|
555
|
+
{form.formState.errors.email && <span>{form.formState.errors.email.message}</span>}
|
|
556
|
+
|
|
557
|
+
<ValfuseController
|
|
558
|
+
control={form.control}
|
|
559
|
+
name="age"
|
|
560
|
+
render={({ field, fieldState }) => (
|
|
561
|
+
<NumberPicker
|
|
562
|
+
value={field.value}
|
|
563
|
+
onChange={field.onChange}
|
|
564
|
+
error={fieldState.error?.message}
|
|
565
|
+
/>
|
|
566
|
+
)}
|
|
567
|
+
/>
|
|
568
|
+
|
|
569
|
+
<button type="submit" disabled={form.formState.isSubmitting}>Save</button>
|
|
570
|
+
<button type="button" onClick={() => form.reset()}>Reset</button>
|
|
571
|
+
<pre>{JSON.stringify(form.watch(), null, 2)}</pre>
|
|
572
|
+
</form>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Subscribe to field changes
|
|
578
|
+
|
|
579
|
+
```tsx
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
const unsub = form.watch("email", (value) => {
|
|
582
|
+
console.log("email changed:", value);
|
|
583
|
+
});
|
|
584
|
+
return unsub;
|
|
585
|
+
}, [form]);
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
### Localize the form
|
|
589
|
+
|
|
590
|
+
```tsx
|
|
591
|
+
import { LocalizationProvider, useLocalization, localStorageStrategy } from "@valfuse-node/react";
|
|
592
|
+
import manifest from "./loc/manifest.json";
|
|
593
|
+
|
|
594
|
+
export function App() {
|
|
595
|
+
return (
|
|
596
|
+
<LocalizationProvider manifest={manifest} storage={localStorageStrategy()}>
|
|
597
|
+
<UserForm />
|
|
598
|
+
</LocalizationProvider>
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function UserFormHeader() {
|
|
603
|
+
const { translate, locale, setLocale } = useLocalization();
|
|
604
|
+
return (
|
|
605
|
+
<header>
|
|
606
|
+
<h1>{translate("user.form.title")}</h1>
|
|
607
|
+
<select value={locale} onChange={(e) => setLocale(e.target.value)}>
|
|
608
|
+
<option value="en">English</option>
|
|
609
|
+
<option value="id">Bahasa Indonesia</option>
|
|
610
|
+
</select>
|
|
611
|
+
</header>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
---
|
|
617
|
+
|
|
618
|
+
## License
|
|
619
|
+
|
|
620
|
+
[MIT](../../LICENSE)
|