@coherent.js/forms 1.1.0 → 2.0.0-rc.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/README.md +162 -243
- package/dist/csrf.js +57 -0
- package/dist/csrf.js.map +7 -0
- package/dist/form-builder.js +483 -27
- package/dist/form-builder.js.map +3 -3
- package/dist/form-hydration.js +317 -222
- package/dist/form-hydration.js.map +3 -3
- package/dist/index.js +675 -555
- package/dist/index.js.map +4 -4
- package/dist/validation.js +376 -61
- package/dist/validation.js.map +3 -3
- package/dist/validators.js +388 -294
- package/dist/validators.js.map +4 -4
- package/package.json +6 -3
- package/types/csrf.d.ts +45 -0
- package/types/index.d.ts +185 -41
package/dist/index.js
CHANGED
|
@@ -1,5 +1,484 @@
|
|
|
1
1
|
// src/form-builder.js
|
|
2
2
|
import { render as renderToHTML } from "@coherent.js/core";
|
|
3
|
+
|
|
4
|
+
// src/patterns.js
|
|
5
|
+
var EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
|
|
6
|
+
var EMAIL_MAX_LENGTH = 254;
|
|
7
|
+
function isEmailShaped(value) {
|
|
8
|
+
return typeof value === "string" && value.length <= EMAIL_MAX_LENGTH && EMAIL_PATTERN.test(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/rules.js
|
|
12
|
+
var BUILTIN = /* @__PURE__ */ Symbol("coherent.forms.builtin");
|
|
13
|
+
var DESCRIPTORS = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
var HELPER_NAMES = /* @__PURE__ */ new Set(["get", "compose", "debounce", "cancellable", "when", "chain"]);
|
|
15
|
+
var isEmpty = (value) => value === null || value === void 0 || value === "";
|
|
16
|
+
var isOptionsObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof RegExp);
|
|
17
|
+
function argsFromString(params, kind, text) {
|
|
18
|
+
if (text === void 0 || text.trim() === "") return [];
|
|
19
|
+
if (params.length === 0) return [text.trim()];
|
|
20
|
+
if (kind === "list") {
|
|
21
|
+
return [text.split(",").map((part) => part.trim()).filter(Boolean)];
|
|
22
|
+
}
|
|
23
|
+
if (kind === "regexp") {
|
|
24
|
+
try {
|
|
25
|
+
return [new RegExp(text)];
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const comma = text.indexOf(",");
|
|
31
|
+
const raw = (comma === -1 ? text : text.slice(0, comma)).trim();
|
|
32
|
+
const message = comma === -1 ? "" : text.slice(comma + 1).trim();
|
|
33
|
+
let arg = raw;
|
|
34
|
+
if (kind === "number") {
|
|
35
|
+
arg = Number(raw);
|
|
36
|
+
if (raw === "" || Number.isNaN(arg)) return null;
|
|
37
|
+
}
|
|
38
|
+
return message ? [arg, message] : [arg];
|
|
39
|
+
}
|
|
40
|
+
function defineRule(name, { params = [], stringArg = "string", valid, message }) {
|
|
41
|
+
const fromOptions = (options = {}) => {
|
|
42
|
+
const config = { message: options.message };
|
|
43
|
+
for (const [key, ...aliases] of params) {
|
|
44
|
+
config[key] = [key, ...aliases].map((alias) => options[alias]).find((v) => v !== void 0);
|
|
45
|
+
}
|
|
46
|
+
return config;
|
|
47
|
+
};
|
|
48
|
+
const check = (value, config, formData) => {
|
|
49
|
+
if (valid(value, config, formData || {})) return null;
|
|
50
|
+
return config.message || builtin.message || message(config);
|
|
51
|
+
};
|
|
52
|
+
const factory = (...args) => {
|
|
53
|
+
const config = { message: args[params.length] };
|
|
54
|
+
params.forEach(([key], index) => {
|
|
55
|
+
config[key] = args[index];
|
|
56
|
+
});
|
|
57
|
+
const rule = (value, formData, _translator, allValues) => check(value, config, allValues ?? formData);
|
|
58
|
+
DESCRIPTORS.set(rule, { name, args });
|
|
59
|
+
return rule;
|
|
60
|
+
};
|
|
61
|
+
const isDirectCall = (args) => {
|
|
62
|
+
if (args.length >= 2) {
|
|
63
|
+
return params.length === 0 || isOptionsObject(args[1]);
|
|
64
|
+
}
|
|
65
|
+
if (args.length === 1 && params.length === 0) {
|
|
66
|
+
const [arg] = args;
|
|
67
|
+
return arg === "" || arg !== void 0 && arg !== null && typeof arg !== "string";
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
};
|
|
71
|
+
function builtin(...args) {
|
|
72
|
+
if (isDirectCall(args)) {
|
|
73
|
+
const [value, options, , allValues] = args;
|
|
74
|
+
return check(value, fromOptions(isOptionsObject(options) ? options : {}), allValues);
|
|
75
|
+
}
|
|
76
|
+
return factory(...args);
|
|
77
|
+
}
|
|
78
|
+
Object.defineProperty(builtin, "name", { value: name });
|
|
79
|
+
builtin[BUILTIN] = {
|
|
80
|
+
name,
|
|
81
|
+
factory,
|
|
82
|
+
argsFromString: (text) => argsFromString(params, stringArg, text)
|
|
83
|
+
};
|
|
84
|
+
return builtin;
|
|
85
|
+
}
|
|
86
|
+
var toNumber = (value) => typeof value === "number" ? value : Number(value);
|
|
87
|
+
function testRegExp(regex, value) {
|
|
88
|
+
if (!(regex instanceof RegExp)) return true;
|
|
89
|
+
regex.lastIndex = 0;
|
|
90
|
+
return regex.test(String(value));
|
|
91
|
+
}
|
|
92
|
+
function fileMatchesType(file, allowedTypes) {
|
|
93
|
+
const fileType = file.type;
|
|
94
|
+
const fileExt = file.name ? file.name.split(".").pop().toLowerCase() : "";
|
|
95
|
+
return allowedTypes.some((type) => {
|
|
96
|
+
if (type.startsWith(".")) {
|
|
97
|
+
return fileExt === type.slice(1).toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
if (type.includes("/")) {
|
|
100
|
+
if (type.endsWith("/*")) {
|
|
101
|
+
return fileType.startsWith(type.replace("/*", "/"));
|
|
102
|
+
}
|
|
103
|
+
return fileType === type;
|
|
104
|
+
}
|
|
105
|
+
return fileExt === type.toLowerCase();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
var validators = {
|
|
109
|
+
required: defineRule("required", {
|
|
110
|
+
valid: (value) => !isEmpty(value),
|
|
111
|
+
message: () => "This field is required"
|
|
112
|
+
}),
|
|
113
|
+
email: defineRule("email", {
|
|
114
|
+
valid: (value) => isEmpty(value) || isEmailShaped(value),
|
|
115
|
+
message: () => "Invalid email address"
|
|
116
|
+
}),
|
|
117
|
+
url: defineRule("url", {
|
|
118
|
+
valid: (value) => {
|
|
119
|
+
if (isEmpty(value)) return true;
|
|
120
|
+
try {
|
|
121
|
+
new URL(value);
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
message: () => "Invalid URL"
|
|
128
|
+
}),
|
|
129
|
+
minLength: defineRule("minLength", {
|
|
130
|
+
params: [["min", "minLength"]],
|
|
131
|
+
stringArg: "number",
|
|
132
|
+
valid: (value, { min }) => !value || !(value.length < (min ?? 0)),
|
|
133
|
+
message: ({ min }) => `Minimum length is ${min}`
|
|
134
|
+
}),
|
|
135
|
+
maxLength: defineRule("maxLength", {
|
|
136
|
+
params: [["max", "maxLength"]],
|
|
137
|
+
stringArg: "number",
|
|
138
|
+
valid: (value, { max }) => !value || !(value.length > (max ?? Infinity)),
|
|
139
|
+
message: ({ max }) => `Maximum length is ${max}`
|
|
140
|
+
}),
|
|
141
|
+
// Empty values pass (combine with `required`); a non-numeric value fails.
|
|
142
|
+
min: defineRule("min", {
|
|
143
|
+
params: [["min"]],
|
|
144
|
+
stringArg: "number",
|
|
145
|
+
valid: (value, { min }) => {
|
|
146
|
+
if (isEmpty(value)) return true;
|
|
147
|
+
const number = toNumber(value);
|
|
148
|
+
return !Number.isNaN(number) && !(number < (min ?? -Infinity));
|
|
149
|
+
},
|
|
150
|
+
message: ({ min }) => `Minimum value is ${min}`
|
|
151
|
+
}),
|
|
152
|
+
max: defineRule("max", {
|
|
153
|
+
params: [["max"]],
|
|
154
|
+
stringArg: "number",
|
|
155
|
+
valid: (value, { max }) => {
|
|
156
|
+
if (isEmpty(value)) return true;
|
|
157
|
+
const number = toNumber(value);
|
|
158
|
+
return !Number.isNaN(number) && !(number > (max ?? Infinity));
|
|
159
|
+
},
|
|
160
|
+
message: ({ max }) => `Maximum value is ${max}`
|
|
161
|
+
}),
|
|
162
|
+
pattern: defineRule("pattern", {
|
|
163
|
+
params: [["pattern", "regex"]],
|
|
164
|
+
stringArg: "regexp",
|
|
165
|
+
valid: (value, { pattern }) => isEmpty(value) || testRegExp(pattern, value),
|
|
166
|
+
message: () => "Invalid format"
|
|
167
|
+
}),
|
|
168
|
+
/** Equal to another field, even when empty */
|
|
169
|
+
matches: defineRule("matches", {
|
|
170
|
+
params: [["field", "fieldName"]],
|
|
171
|
+
valid: (value, { field }, formData) => value === formData[field],
|
|
172
|
+
message: () => "Fields do not match"
|
|
173
|
+
}),
|
|
174
|
+
/** Equal to another field; an empty value passes */
|
|
175
|
+
match: defineRule("match", {
|
|
176
|
+
params: [["field", "fieldName"]],
|
|
177
|
+
valid: (value, { field }, formData) => !value || value === formData[field],
|
|
178
|
+
message: ({ field }) => `Must match ${field}`
|
|
179
|
+
}),
|
|
180
|
+
oneOf: defineRule("oneOf", {
|
|
181
|
+
params: [["options", "values"]],
|
|
182
|
+
stringArg: "list",
|
|
183
|
+
valid: (value, { options }) => !value || !Array.isArray(options) || options.includes(value),
|
|
184
|
+
message: () => "Invalid option"
|
|
185
|
+
}),
|
|
186
|
+
custom: defineRule("custom", {
|
|
187
|
+
params: [["validator", "fn"]],
|
|
188
|
+
valid: (value, { validator }, formData) => typeof validator !== "function" || Boolean(validator(value, formData)),
|
|
189
|
+
message: () => "Validation failed"
|
|
190
|
+
}),
|
|
191
|
+
number: defineRule("number", {
|
|
192
|
+
valid: (value) => isEmpty(value) || !Number.isNaN(Number(value)),
|
|
193
|
+
message: () => "Must be a valid number"
|
|
194
|
+
}),
|
|
195
|
+
integer: defineRule("integer", {
|
|
196
|
+
valid: (value) => isEmpty(value) || Number.isInteger(Number(value)),
|
|
197
|
+
message: () => "Must be a whole number"
|
|
198
|
+
}),
|
|
199
|
+
phone: defineRule("phone", {
|
|
200
|
+
valid: (value) => !value || /^[\d\s\-+()]+$/.test(value) && value.replace(/\D/g, "").length >= 10,
|
|
201
|
+
message: () => "Please enter a valid phone number"
|
|
202
|
+
}),
|
|
203
|
+
date: defineRule("date", {
|
|
204
|
+
valid: (value) => !value || !Number.isNaN(new Date(value).getTime()),
|
|
205
|
+
message: () => "Please enter a valid date"
|
|
206
|
+
}),
|
|
207
|
+
alpha: defineRule("alpha", {
|
|
208
|
+
valid: (value) => !value || /^[a-zA-Z]+$/.test(value),
|
|
209
|
+
message: () => "Must contain only letters"
|
|
210
|
+
}),
|
|
211
|
+
alphanumeric: defineRule("alphanumeric", {
|
|
212
|
+
valid: (value) => !value || /^[a-zA-Z0-9]+$/.test(value),
|
|
213
|
+
message: () => "Must contain only letters and numbers"
|
|
214
|
+
}),
|
|
215
|
+
uppercase: defineRule("uppercase", {
|
|
216
|
+
valid: (value) => !value || value === String(value).toUpperCase(),
|
|
217
|
+
message: () => "Must be uppercase"
|
|
218
|
+
}),
|
|
219
|
+
fileType: defineRule("fileType", {
|
|
220
|
+
params: [["accept", "types"]],
|
|
221
|
+
stringArg: "list",
|
|
222
|
+
valid: (value, { accept }) => !value || value.type === void 0 || fileMatchesType(value, accept || []),
|
|
223
|
+
message: ({ accept }) => `File type must be one of: ${(accept || []).join(", ")}`
|
|
224
|
+
}),
|
|
225
|
+
fileSize: defineRule("fileSize", {
|
|
226
|
+
params: [["maxSize"]],
|
|
227
|
+
stringArg: "number",
|
|
228
|
+
valid: (value, { maxSize }) => !value || value.size === void 0 || !(value.size > (maxSize ?? Infinity)),
|
|
229
|
+
message: ({ maxSize }) => `File size must be less than ${((maxSize ?? Infinity) / (1024 * 1024)).toFixed(2)}MB`
|
|
230
|
+
}),
|
|
231
|
+
fileExtension: defineRule("fileExtension", {
|
|
232
|
+
params: [["extensions"]],
|
|
233
|
+
stringArg: "list",
|
|
234
|
+
valid: (value, { extensions }) => {
|
|
235
|
+
if (!value) return true;
|
|
236
|
+
const fileName = value.name || value;
|
|
237
|
+
const ext = `.${String(fileName).split(".").pop().toLowerCase()}`;
|
|
238
|
+
return (extensions || []).some((allowed) => ext === allowed.toLowerCase());
|
|
239
|
+
},
|
|
240
|
+
message: ({ extensions }) => `File extension must be one of: ${(extensions || []).join(", ")}`
|
|
241
|
+
}),
|
|
242
|
+
/** A registered validator or built-in by name */
|
|
243
|
+
get: (name) => Object.hasOwn(validators, name) ? validators[name] : void 0,
|
|
244
|
+
/** Combine validators into one returning the first error */
|
|
245
|
+
compose: (validatorList) => {
|
|
246
|
+
const rules = validatorList.map(resolveValidator);
|
|
247
|
+
return (value, options, translator, allValues) => {
|
|
248
|
+
for (const rule of rules) {
|
|
249
|
+
const error = rule ? rule(value, options, translator, allValues) : null;
|
|
250
|
+
if (error) {
|
|
251
|
+
return error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
/** Debounce an async validator */
|
|
258
|
+
debounce: (validator, delay = 300) => {
|
|
259
|
+
let timeoutId;
|
|
260
|
+
return (value) => {
|
|
261
|
+
return new Promise((resolve) => {
|
|
262
|
+
clearTimeout(timeoutId);
|
|
263
|
+
timeoutId = setTimeout(async () => {
|
|
264
|
+
const result = await validator(value);
|
|
265
|
+
resolve(result);
|
|
266
|
+
}, delay);
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
/** Cancellable async validator */
|
|
271
|
+
cancellable: (validator) => {
|
|
272
|
+
let abortController;
|
|
273
|
+
const wrapped = async (value) => {
|
|
274
|
+
if (abortController) {
|
|
275
|
+
abortController.abort();
|
|
276
|
+
}
|
|
277
|
+
abortController = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
278
|
+
try {
|
|
279
|
+
return await validator(value, abortController ? abortController.signal : null);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (error.name === "AbortError") {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
wrapped.cancel = () => {
|
|
288
|
+
if (abortController) {
|
|
289
|
+
abortController.abort();
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
return wrapped;
|
|
293
|
+
},
|
|
294
|
+
/** Run `validator` only when `condition` holds */
|
|
295
|
+
when: (condition, validator) => {
|
|
296
|
+
const rule = resolveValidator(validator);
|
|
297
|
+
return (value, options = {}, translator, allValues = {}) => {
|
|
298
|
+
const context = options.min !== void 0 || options.max !== void 0 ? allValues : options;
|
|
299
|
+
const shouldValidate = typeof condition === "function" ? condition(value, context) : condition;
|
|
300
|
+
if (!shouldValidate) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
return rule ? rule(value, options, translator, allValues) : null;
|
|
304
|
+
};
|
|
305
|
+
},
|
|
306
|
+
/** Validator chain builder */
|
|
307
|
+
chain: (options = {}) => {
|
|
308
|
+
const validatorList = [];
|
|
309
|
+
const stopOnFirstError = options.stopOnFirstError !== false;
|
|
310
|
+
const direct = (name) => (opts) => {
|
|
311
|
+
validatorList.push((v, o, t, a) => validators[name](v, opts || o || {}, t, a));
|
|
312
|
+
return chain;
|
|
313
|
+
};
|
|
314
|
+
const chain = {
|
|
315
|
+
required: direct("required"),
|
|
316
|
+
email: direct("email"),
|
|
317
|
+
minLength: direct("minLength"),
|
|
318
|
+
maxLength: direct("maxLength"),
|
|
319
|
+
custom: (fn, message) => {
|
|
320
|
+
validatorList.push((v, o, t, a) => {
|
|
321
|
+
const result = fn(v, a);
|
|
322
|
+
return result === null || result === true || result === void 0 ? null : message || result;
|
|
323
|
+
});
|
|
324
|
+
return chain;
|
|
325
|
+
},
|
|
326
|
+
validate: (value, opts, translator, allValues) => {
|
|
327
|
+
if (stopOnFirstError) {
|
|
328
|
+
for (const validator of validatorList) {
|
|
329
|
+
const error = validator(value, opts, translator, allValues);
|
|
330
|
+
if (error) {
|
|
331
|
+
return error;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const errors = [];
|
|
337
|
+
for (const validator of validatorList) {
|
|
338
|
+
const error = validator(value, opts, translator, allValues);
|
|
339
|
+
if (error) {
|
|
340
|
+
errors.push(error);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return errors.length > 0 ? errors : null;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
return chain;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
function lookup(name) {
|
|
350
|
+
const found = Object.hasOwn(validators, name) && !HELPER_NAMES.has(name) ? validators[name] : null;
|
|
351
|
+
return typeof found === "function" ? found : null;
|
|
352
|
+
}
|
|
353
|
+
function resolveString(entry) {
|
|
354
|
+
const exact = lookup(entry);
|
|
355
|
+
const colon = exact ? -1 : entry.indexOf(":");
|
|
356
|
+
const name = colon === -1 ? entry : entry.slice(0, colon).trim();
|
|
357
|
+
const found = exact ?? lookup(name);
|
|
358
|
+
if (!found) return null;
|
|
359
|
+
const text = colon === -1 ? void 0 : entry.slice(colon + 1);
|
|
360
|
+
if (!found[BUILTIN]) {
|
|
361
|
+
return text === void 0 ? { name, args: [], validator: found } : null;
|
|
362
|
+
}
|
|
363
|
+
const args = found[BUILTIN].argsFromString(text);
|
|
364
|
+
if (!args) return null;
|
|
365
|
+
return { name: found[BUILTIN].name, args, validator: found[BUILTIN].factory(...args) };
|
|
366
|
+
}
|
|
367
|
+
function resolveValidator(entry) {
|
|
368
|
+
if (typeof entry === "string") {
|
|
369
|
+
return resolveString(entry)?.validator ?? null;
|
|
370
|
+
}
|
|
371
|
+
if (typeof entry !== "function") return null;
|
|
372
|
+
return entry[BUILTIN] ? entry[BUILTIN].factory() : entry;
|
|
373
|
+
}
|
|
374
|
+
function validateField(value, validatorList, formData = {}) {
|
|
375
|
+
const list = Array.isArray(validatorList) ? validatorList : [validatorList];
|
|
376
|
+
for (const entry of list) {
|
|
377
|
+
const validator = resolveValidator(entry);
|
|
378
|
+
const error = validator ? validator(value, formData) : null;
|
|
379
|
+
if (error) {
|
|
380
|
+
return error;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
function validateForm(formData, fieldValidators) {
|
|
386
|
+
const errors = {};
|
|
387
|
+
for (const [fieldName, validatorList] of Object.entries(fieldValidators)) {
|
|
388
|
+
const value = formData[fieldName];
|
|
389
|
+
const error = validateField(value, validatorList, formData);
|
|
390
|
+
if (error) {
|
|
391
|
+
errors[fieldName] = error;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return Object.keys(errors).length > 0 ? errors : null;
|
|
395
|
+
}
|
|
396
|
+
function wrapValidator(validatorFn, message) {
|
|
397
|
+
return (value, options, translator, allValues) => {
|
|
398
|
+
const result = validatorFn(value, options, translator, allValues);
|
|
399
|
+
if (typeof result === "string") {
|
|
400
|
+
return result;
|
|
401
|
+
}
|
|
402
|
+
if (!result) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
return message || "Validation failed";
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function registerValidator(name, validatorFn) {
|
|
409
|
+
validators[name] = validatorFn;
|
|
410
|
+
if (typeof validatorFn === "function" && !validatorFn[BUILTIN]) {
|
|
411
|
+
DESCRIPTORS.set(validatorFn, { name, args: [] });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function composeValidators(...validatorFns) {
|
|
415
|
+
return validators.compose(validatorFns);
|
|
416
|
+
}
|
|
417
|
+
function serializeArg(arg) {
|
|
418
|
+
if (arg instanceof RegExp) return { $regexp: [arg.source, arg.flags] };
|
|
419
|
+
return arg;
|
|
420
|
+
}
|
|
421
|
+
function reviveArg(arg) {
|
|
422
|
+
if (isOptionsObject(arg) && Array.isArray(arg.$regexp)) {
|
|
423
|
+
try {
|
|
424
|
+
return new RegExp(arg.$regexp[0], arg.$regexp[1]);
|
|
425
|
+
} catch {
|
|
426
|
+
return void 0;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return arg;
|
|
430
|
+
}
|
|
431
|
+
function isSerializable(arg) {
|
|
432
|
+
if (arg === void 0 || arg === null) return true;
|
|
433
|
+
if (arg instanceof RegExp) return true;
|
|
434
|
+
if (Array.isArray(arg)) return arg.every(isSerializable);
|
|
435
|
+
return ["string", "number", "boolean"].includes(typeof arg);
|
|
436
|
+
}
|
|
437
|
+
function describeValidator(entry) {
|
|
438
|
+
let descriptor;
|
|
439
|
+
if (typeof entry === "string") {
|
|
440
|
+
descriptor = resolveString(entry);
|
|
441
|
+
} else if (typeof entry === "function") {
|
|
442
|
+
descriptor = entry[BUILTIN] ? { name: entry[BUILTIN].name, args: [] } : DESCRIPTORS.get(entry);
|
|
443
|
+
}
|
|
444
|
+
if (!descriptor || !descriptor.args.every(isSerializable)) return null;
|
|
445
|
+
const args = [...descriptor.args];
|
|
446
|
+
while (args.length > 0 && args[args.length - 1] === void 0) args.pop();
|
|
447
|
+
return { name: descriptor.name, args: args.map(serializeArg) };
|
|
448
|
+
}
|
|
449
|
+
function serializeValidators(validatorList) {
|
|
450
|
+
const specs = (validatorList || []).map(describeValidator).filter(Boolean);
|
|
451
|
+
return specs.length > 0 ? JSON.stringify(specs) : null;
|
|
452
|
+
}
|
|
453
|
+
function parseValidators(attribute) {
|
|
454
|
+
if (!attribute) return [];
|
|
455
|
+
const text = String(attribute).trim();
|
|
456
|
+
let specs;
|
|
457
|
+
if (text.startsWith("[")) {
|
|
458
|
+
try {
|
|
459
|
+
specs = JSON.parse(text);
|
|
460
|
+
} catch {
|
|
461
|
+
return [];
|
|
462
|
+
}
|
|
463
|
+
if (!Array.isArray(specs)) return [];
|
|
464
|
+
} else {
|
|
465
|
+
specs = text.split(",").map((part) => {
|
|
466
|
+
const [name, ...params] = part.trim().split(":");
|
|
467
|
+
return { name, args: params.map((p) => p !== "" && !Number.isNaN(Number(p)) ? Number(p) : p) };
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return specs.map((spec) => {
|
|
471
|
+
if (!spec || typeof spec.name !== "string") return null;
|
|
472
|
+
const { name } = spec;
|
|
473
|
+
if (!Object.hasOwn(validators, name) || HELPER_NAMES.has(name)) return null;
|
|
474
|
+
const entry = validators[name];
|
|
475
|
+
if (typeof entry !== "function") return null;
|
|
476
|
+
const args = Array.isArray(spec.args) ? spec.args.map(reviveArg) : [];
|
|
477
|
+
return entry[BUILTIN] ? entry[BUILTIN].factory(...args) : entry;
|
|
478
|
+
}).filter(Boolean);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/form-builder.js
|
|
3
482
|
var DEFAULT_CLASS_NAMES = {
|
|
4
483
|
/** Wrapper around label, control and error */
|
|
5
484
|
field: "form-field",
|
|
@@ -32,10 +511,11 @@ function safeAttributes(attributes) {
|
|
|
32
511
|
}
|
|
33
512
|
return safe;
|
|
34
513
|
}
|
|
514
|
+
var DEFAULT_CSRF_FIELD_NAME = "_csrf";
|
|
35
515
|
function joinClasses(...names) {
|
|
36
516
|
return names.filter(Boolean).join(" ");
|
|
37
517
|
}
|
|
38
|
-
var FormBuilder = class {
|
|
518
|
+
var FormBuilder = class _FormBuilder {
|
|
39
519
|
constructor(options = {}) {
|
|
40
520
|
this.options = {
|
|
41
521
|
validateOnChange: true,
|
|
@@ -218,8 +698,7 @@ var FormBuilder = class {
|
|
|
218
698
|
}
|
|
219
699
|
if (value) {
|
|
220
700
|
if (field.type === "email") {
|
|
221
|
-
|
|
222
|
-
if (!emailRegex.test(value)) {
|
|
701
|
+
if (!isEmailShaped(value)) {
|
|
223
702
|
const error = "Please enter a valid email address";
|
|
224
703
|
this.errors[name] = error;
|
|
225
704
|
return error;
|
|
@@ -247,8 +726,9 @@ var FormBuilder = class {
|
|
|
247
726
|
return error;
|
|
248
727
|
}
|
|
249
728
|
}
|
|
250
|
-
for (const
|
|
251
|
-
const
|
|
729
|
+
for (const entry of field.validators || []) {
|
|
730
|
+
const validator = resolveValidator(entry);
|
|
731
|
+
const error = validator ? validator(value, this.values) : null;
|
|
252
732
|
if (error) {
|
|
253
733
|
this.errors[name] = error;
|
|
254
734
|
return error;
|
|
@@ -328,20 +808,59 @@ var FormBuilder = class {
|
|
|
328
808
|
touch(name) {
|
|
329
809
|
this.touched[name] = true;
|
|
330
810
|
}
|
|
811
|
+
/**
|
|
812
|
+
* A copy of this form's definition — fields, groups, options and handlers —
|
|
813
|
+
* with fresh values, errors and touched state.
|
|
814
|
+
*
|
|
815
|
+
* A FormBuilder holds the state of one submission. On a server, keep the
|
|
816
|
+
* shared definition at module scope and fork it per request, so one user's
|
|
817
|
+
* submitted values and errors never render into another user's page.
|
|
818
|
+
*/
|
|
819
|
+
fork() {
|
|
820
|
+
const copy = new _FormBuilder({ ...this.options });
|
|
821
|
+
for (const [name, config] of this.fields) {
|
|
822
|
+
copy.fields.set(name, config);
|
|
823
|
+
}
|
|
824
|
+
for (const [name, group] of this.groups) {
|
|
825
|
+
copy.groups.set(name, group);
|
|
826
|
+
}
|
|
827
|
+
copy.values = { ...this.initialValues };
|
|
828
|
+
copy.initialValues = { ...this.initialValues };
|
|
829
|
+
copy.submitHandler = this.submitHandler;
|
|
830
|
+
copy.errorHandler = this.errorHandler;
|
|
831
|
+
return copy;
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* The values, errors and touched state a render uses.
|
|
835
|
+
*
|
|
836
|
+
* Passing any of `values`, `errors` or `touched` to buildForm renders from
|
|
837
|
+
* those alone — the instance's own state is neither read nor changed — so a
|
|
838
|
+
* shared builder can render per-request state. Values are merged over the
|
|
839
|
+
* fields' default values, and fields with an error count as touched unless
|
|
840
|
+
* `touched` is given.
|
|
841
|
+
*/
|
|
842
|
+
resolveRenderState(options = {}) {
|
|
843
|
+
const { values, errors, touched } = options;
|
|
844
|
+
if (values === void 0 && errors === void 0 && touched === void 0) {
|
|
845
|
+
return { values: this.values, errors: this.errors, touched: this.touched };
|
|
846
|
+
}
|
|
847
|
+
const renderErrors = errors || {};
|
|
848
|
+
return {
|
|
849
|
+
values: { ...this.initialValues, ...values },
|
|
850
|
+
errors: renderErrors,
|
|
851
|
+
touched: touched || Object.fromEntries(Object.keys(renderErrors).map((name) => [name, true]))
|
|
852
|
+
};
|
|
853
|
+
}
|
|
331
854
|
/**
|
|
332
855
|
* Build input component with validation metadata for hydration
|
|
333
856
|
*/
|
|
334
|
-
buildInput(name, classNames = this.resolveClassNames()) {
|
|
857
|
+
buildInput(name, classNames = this.resolveClassNames(), state = this.resolveRenderState()) {
|
|
335
858
|
const field = this.fields.get(name);
|
|
336
859
|
if (!field) return null;
|
|
337
|
-
const value =
|
|
338
|
-
const error =
|
|
339
|
-
const isTouched =
|
|
340
|
-
const
|
|
341
|
-
if (typeof v === "function") return v.name || "custom";
|
|
342
|
-
if (typeof v === "string") return v;
|
|
343
|
-
return null;
|
|
344
|
-
}).filter(Boolean).join(",");
|
|
860
|
+
const value = state.values[name] || "";
|
|
861
|
+
const error = state.errors[name];
|
|
862
|
+
const isTouched = state.touched[name];
|
|
863
|
+
const validatorSpec = serializeValidators(field.validators);
|
|
345
864
|
const controlClass = joinClasses(
|
|
346
865
|
classNames.control,
|
|
347
866
|
field.className,
|
|
@@ -366,8 +885,8 @@ var FormBuilder = class {
|
|
|
366
885
|
inputProps.required = true;
|
|
367
886
|
inputProps["data-required"] = "true";
|
|
368
887
|
}
|
|
369
|
-
if (
|
|
370
|
-
inputProps["data-validators"] =
|
|
888
|
+
if (validatorSpec) {
|
|
889
|
+
inputProps["data-validators"] = validatorSpec;
|
|
371
890
|
}
|
|
372
891
|
if (field.type === "textarea" || field.type === "select") {
|
|
373
892
|
const { type: _type, value: _value, ...rest } = inputProps;
|
|
@@ -409,9 +928,9 @@ var FormBuilder = class {
|
|
|
409
928
|
/**
|
|
410
929
|
* Build error component
|
|
411
930
|
*/
|
|
412
|
-
buildError(name, classNames = this.resolveClassNames()) {
|
|
413
|
-
const error =
|
|
414
|
-
const isTouched =
|
|
931
|
+
buildError(name, classNames = this.resolveClassNames(), state = this.resolveRenderState()) {
|
|
932
|
+
const error = state.errors[name];
|
|
933
|
+
const isTouched = state.touched[name];
|
|
415
934
|
if (!error || !isTouched) return null;
|
|
416
935
|
const props = { id: `${name}-error`, role: "alert", text: error };
|
|
417
936
|
if (classNames.error) props.className = classNames.error;
|
|
@@ -426,14 +945,14 @@ var FormBuilder = class {
|
|
|
426
945
|
/**
|
|
427
946
|
* Build complete field component
|
|
428
947
|
*/
|
|
429
|
-
buildField(name, classNames = this.resolveClassNames()) {
|
|
948
|
+
buildField(name, classNames = this.resolveClassNames(), state = this.resolveRenderState()) {
|
|
430
949
|
const field = this.fields.get(name);
|
|
431
950
|
if (!field) return null;
|
|
432
951
|
const children = [
|
|
433
952
|
this.buildLabel(name, classNames),
|
|
434
|
-
this.buildInput(name, classNames)
|
|
953
|
+
this.buildInput(name, classNames, state)
|
|
435
954
|
];
|
|
436
|
-
const error = this.buildError(name, classNames);
|
|
955
|
+
const error = this.buildError(name, classNames, state);
|
|
437
956
|
if (error) {
|
|
438
957
|
children.push(error);
|
|
439
958
|
}
|
|
@@ -443,14 +962,31 @@ var FormBuilder = class {
|
|
|
443
962
|
}
|
|
444
963
|
/**
|
|
445
964
|
* Build entire form
|
|
965
|
+
*
|
|
966
|
+
* `options.values`, `options.errors` and `options.touched` render
|
|
967
|
+
* per-request state without touching the builder's own (see
|
|
968
|
+
* resolveRenderState). `options.csrfToken` adds a hidden `_csrf` input (the
|
|
969
|
+
* name is `options.csrfFieldName`); create the token with
|
|
970
|
+
* `@coherent.js/forms/csrf`.
|
|
446
971
|
*/
|
|
447
972
|
buildForm(options = {}) {
|
|
448
|
-
const
|
|
973
|
+
const { values: _values, errors: _errors, touched: _touched, ...formOptions } = options;
|
|
974
|
+
const settings = { ...this.options, ...formOptions };
|
|
449
975
|
const classNames = this.resolveClassNames(options.classNames);
|
|
976
|
+
const state = this.resolveRenderState(options);
|
|
450
977
|
const fields = [];
|
|
978
|
+
if (settings.csrfToken !== void 0 && settings.csrfToken !== null && settings.csrfToken !== "") {
|
|
979
|
+
fields.push({
|
|
980
|
+
input: {
|
|
981
|
+
type: "hidden",
|
|
982
|
+
name: settings.csrfFieldName || DEFAULT_CSRF_FIELD_NAME,
|
|
983
|
+
value: String(settings.csrfToken)
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
}
|
|
451
987
|
for (const [name] of this.fields) {
|
|
452
|
-
if (!this.isFieldVisible(name)) continue;
|
|
453
|
-
fields.push(this.buildField(name, classNames));
|
|
988
|
+
if (!this.isFieldVisible(name, state.values)) continue;
|
|
989
|
+
fields.push(this.buildField(name, classNames, state));
|
|
454
990
|
}
|
|
455
991
|
if (settings.submitButton !== false) {
|
|
456
992
|
const button = { type: "submit", text: settings.submitText || "Submit" };
|
|
@@ -512,13 +1048,13 @@ var FormBuilder = class {
|
|
|
512
1048
|
/**
|
|
513
1049
|
* Check if a field is visible
|
|
514
1050
|
*/
|
|
515
|
-
isFieldVisible(name) {
|
|
1051
|
+
isFieldVisible(name, values = this.values) {
|
|
516
1052
|
const field = this.fields.get(name);
|
|
517
1053
|
if (!field) return false;
|
|
518
1054
|
if (field.visible === false) return false;
|
|
519
1055
|
const showCondition = field.showWhen || field.showIf;
|
|
520
1056
|
if (showCondition) {
|
|
521
|
-
return showCondition(
|
|
1057
|
+
return showCondition(values);
|
|
522
1058
|
}
|
|
523
1059
|
return true;
|
|
524
1060
|
}
|
|
@@ -564,525 +1100,15 @@ function buildForm(config = {}) {
|
|
|
564
1100
|
return builder.buildForm(options);
|
|
565
1101
|
}
|
|
566
1102
|
|
|
567
|
-
// src/
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
return message;
|
|
572
|
-
}
|
|
573
|
-
return null;
|
|
574
|
-
},
|
|
575
|
-
minLength: (min, message = `Minimum length is ${min}`) => (value) => {
|
|
576
|
-
if (value && value.length < min) {
|
|
577
|
-
return message;
|
|
578
|
-
}
|
|
579
|
-
return null;
|
|
580
|
-
},
|
|
581
|
-
maxLength: (max, message = `Maximum length is ${max}`) => (value) => {
|
|
582
|
-
if (value && value.length > max) {
|
|
583
|
-
return message;
|
|
584
|
-
}
|
|
585
|
-
return null;
|
|
586
|
-
},
|
|
587
|
-
min: (min, message = `Minimum value is ${min}`) => (value) => {
|
|
588
|
-
if (value !== null && value !== void 0 && Number(value) < min) {
|
|
589
|
-
return message;
|
|
590
|
-
}
|
|
591
|
-
return null;
|
|
592
|
-
},
|
|
593
|
-
max: (max, message = `Maximum value is ${max}`) => (value) => {
|
|
594
|
-
if (value !== null && value !== void 0 && Number(value) > max) {
|
|
595
|
-
return message;
|
|
596
|
-
}
|
|
597
|
-
return null;
|
|
598
|
-
},
|
|
599
|
-
email: (message = "Invalid email address") => (value) => {
|
|
600
|
-
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
601
|
-
return message;
|
|
602
|
-
}
|
|
603
|
-
return null;
|
|
604
|
-
},
|
|
605
|
-
url: (message = "Invalid URL") => (value) => {
|
|
606
|
-
if (value) {
|
|
607
|
-
try {
|
|
608
|
-
new URL(value);
|
|
609
|
-
} catch {
|
|
610
|
-
return message;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
return null;
|
|
614
|
-
},
|
|
615
|
-
pattern: (regex, message = "Invalid format") => (value) => {
|
|
616
|
-
if (value && !regex.test(value)) {
|
|
617
|
-
return message;
|
|
618
|
-
}
|
|
619
|
-
return null;
|
|
620
|
-
},
|
|
621
|
-
matches: (fieldName, message = "Fields do not match") => (value, formData) => {
|
|
622
|
-
if (value !== formData[fieldName]) {
|
|
623
|
-
return message;
|
|
624
|
-
}
|
|
625
|
-
return null;
|
|
626
|
-
},
|
|
627
|
-
oneOf: (options, message = "Invalid option") => (value) => {
|
|
628
|
-
if (value && !options.includes(value)) {
|
|
629
|
-
return message;
|
|
630
|
-
}
|
|
631
|
-
return null;
|
|
632
|
-
},
|
|
633
|
-
custom: (fn, message = "Validation failed") => (value, formData) => {
|
|
634
|
-
if (!fn(value, formData)) {
|
|
635
|
-
return message;
|
|
636
|
-
}
|
|
1103
|
+
// src/form-hydration.js
|
|
1104
|
+
function hydrateForm(formSelector, options = {}) {
|
|
1105
|
+
if (typeof document === "undefined") {
|
|
1106
|
+
console.warn("hydrateForm can only run in browser environment");
|
|
637
1107
|
return null;
|
|
638
1108
|
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
this.schema = schema;
|
|
643
|
-
this.errors = {};
|
|
644
|
-
this.touched = {};
|
|
645
|
-
}
|
|
646
|
-
/**
|
|
647
|
-
* Validate a single field
|
|
648
|
-
*/
|
|
649
|
-
validateField(name, value, formData = {}) {
|
|
650
|
-
const fieldValidators = this.schema[name];
|
|
651
|
-
if (!fieldValidators) {
|
|
652
|
-
return null;
|
|
653
|
-
}
|
|
654
|
-
const validatorArray = Array.isArray(fieldValidators) ? fieldValidators : [fieldValidators];
|
|
655
|
-
for (const validator of validatorArray) {
|
|
656
|
-
const error = validator(value, formData);
|
|
657
|
-
if (error) {
|
|
658
|
-
return error;
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
return null;
|
|
662
|
-
}
|
|
663
|
-
/**
|
|
664
|
-
* Validate entire form
|
|
665
|
-
*/
|
|
666
|
-
validate(formData) {
|
|
667
|
-
const errors = {};
|
|
668
|
-
let isValid = true;
|
|
669
|
-
for (const [name, value] of Object.entries(formData)) {
|
|
670
|
-
const error = this.validateField(name, value, formData);
|
|
671
|
-
if (error) {
|
|
672
|
-
errors[name] = error;
|
|
673
|
-
isValid = false;
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
for (const name of Object.keys(this.schema)) {
|
|
677
|
-
if (!(name in formData)) {
|
|
678
|
-
const error = this.validateField(name, void 0, formData);
|
|
679
|
-
if (error) {
|
|
680
|
-
errors[name] = error;
|
|
681
|
-
isValid = false;
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
this.errors = errors;
|
|
686
|
-
return { isValid, errors };
|
|
687
|
-
}
|
|
688
|
-
/**
|
|
689
|
-
* Mark field as touched
|
|
690
|
-
*/
|
|
691
|
-
touch(name) {
|
|
692
|
-
this.touched[name] = true;
|
|
693
|
-
}
|
|
694
|
-
/**
|
|
695
|
-
* Check if field is touched
|
|
696
|
-
*/
|
|
697
|
-
isTouched(name) {
|
|
698
|
-
return this.touched[name] || false;
|
|
699
|
-
}
|
|
700
|
-
/**
|
|
701
|
-
* Get error for field
|
|
702
|
-
*/
|
|
703
|
-
getError(name) {
|
|
704
|
-
return this.errors[name] || null;
|
|
705
|
-
}
|
|
706
|
-
/**
|
|
707
|
-
* Check if field has error
|
|
708
|
-
*/
|
|
709
|
-
hasError(name) {
|
|
710
|
-
return !!this.errors[name];
|
|
711
|
-
}
|
|
712
|
-
/**
|
|
713
|
-
* Clear errors
|
|
714
|
-
*/
|
|
715
|
-
clearErrors() {
|
|
716
|
-
this.errors = {};
|
|
717
|
-
}
|
|
718
|
-
/**
|
|
719
|
-
* Clear touched state
|
|
720
|
-
*/
|
|
721
|
-
clearTouched() {
|
|
722
|
-
this.touched = {};
|
|
723
|
-
}
|
|
724
|
-
/**
|
|
725
|
-
* Reset validator
|
|
726
|
-
*/
|
|
727
|
-
reset() {
|
|
728
|
-
this.clearErrors();
|
|
729
|
-
this.clearTouched();
|
|
730
|
-
}
|
|
731
|
-
};
|
|
732
|
-
function createValidator(schema) {
|
|
733
|
-
return new FormValidator(schema);
|
|
734
|
-
}
|
|
735
|
-
function validate(formData, schema) {
|
|
736
|
-
const validator = new FormValidator(schema);
|
|
737
|
-
return validator.validate(formData);
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// src/validators.js
|
|
741
|
-
var validators2 = {
|
|
742
|
-
required: (value, options = {}) => {
|
|
743
|
-
if (value === null || value === void 0 || value === "") {
|
|
744
|
-
return options.message || validators2.required.message || "This field is required";
|
|
745
|
-
}
|
|
746
|
-
return null;
|
|
747
|
-
},
|
|
748
|
-
email: (value) => {
|
|
749
|
-
if (!value) return null;
|
|
750
|
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
751
|
-
if (!emailRegex.test(value)) {
|
|
752
|
-
return "Please enter a valid email address";
|
|
753
|
-
}
|
|
754
|
-
return null;
|
|
755
|
-
},
|
|
756
|
-
minLength: (value, options = {}) => {
|
|
757
|
-
if (!value) return null;
|
|
758
|
-
const min = options.min || 0;
|
|
759
|
-
if (value.length < min) {
|
|
760
|
-
return options.message || `Must be at least ${min} characters`;
|
|
761
|
-
}
|
|
762
|
-
return null;
|
|
763
|
-
},
|
|
764
|
-
maxLength: (value, options = {}) => {
|
|
765
|
-
if (!value) return null;
|
|
766
|
-
const max = options.max || Infinity;
|
|
767
|
-
if (value.length > max) {
|
|
768
|
-
return options.message || `Must be no more than ${max} characters`;
|
|
769
|
-
}
|
|
770
|
-
return null;
|
|
771
|
-
},
|
|
772
|
-
min: (value, options = {}) => {
|
|
773
|
-
if (value === null || value === void 0 || value === "") return null;
|
|
774
|
-
const num = Number(value);
|
|
775
|
-
const minValue = options.min || 0;
|
|
776
|
-
if (isNaN(num) || num < minValue) {
|
|
777
|
-
return options.message || `Must be at least ${minValue}`;
|
|
778
|
-
}
|
|
779
|
-
return null;
|
|
780
|
-
},
|
|
781
|
-
max: (value, options = {}) => {
|
|
782
|
-
if (value === null || value === void 0 || value === "") return null;
|
|
783
|
-
const num = Number(value);
|
|
784
|
-
const maxValue = options.max || Infinity;
|
|
785
|
-
if (isNaN(num) || num > maxValue) {
|
|
786
|
-
return options.message || `Must be no more than ${maxValue}`;
|
|
787
|
-
}
|
|
788
|
-
return null;
|
|
789
|
-
},
|
|
790
|
-
pattern: (value, options = {}) => {
|
|
791
|
-
if (!value) return null;
|
|
792
|
-
const regex = options.pattern || options.regex;
|
|
793
|
-
if (regex && !regex.test(value)) {
|
|
794
|
-
return options.message || "Invalid format";
|
|
795
|
-
}
|
|
796
|
-
return null;
|
|
797
|
-
},
|
|
798
|
-
url: (value) => {
|
|
799
|
-
if (!value) return null;
|
|
800
|
-
try {
|
|
801
|
-
new URL(value);
|
|
802
|
-
return null;
|
|
803
|
-
} catch {
|
|
804
|
-
return "Please enter a valid URL";
|
|
805
|
-
}
|
|
806
|
-
},
|
|
807
|
-
number: (value) => {
|
|
808
|
-
if (value === null || value === void 0 || value === "") return null;
|
|
809
|
-
if (isNaN(Number(value))) {
|
|
810
|
-
return "Must be a valid number";
|
|
811
|
-
}
|
|
812
|
-
return null;
|
|
813
|
-
},
|
|
814
|
-
integer: (value) => {
|
|
815
|
-
if (value === null || value === void 0 || value === "") return null;
|
|
816
|
-
const num = Number(value);
|
|
817
|
-
if (isNaN(num) || !Number.isInteger(num)) {
|
|
818
|
-
return "Must be a whole number";
|
|
819
|
-
}
|
|
820
|
-
return null;
|
|
821
|
-
},
|
|
822
|
-
phone: (value) => {
|
|
823
|
-
if (!value) return null;
|
|
824
|
-
const phoneRegex = /^[\d\s\-\+\(\)]+$/;
|
|
825
|
-
if (!phoneRegex.test(value) || value.replace(/\D/g, "").length < 10) {
|
|
826
|
-
return "Please enter a valid phone number";
|
|
827
|
-
}
|
|
828
|
-
return null;
|
|
829
|
-
},
|
|
830
|
-
date: (value) => {
|
|
831
|
-
if (!value) return null;
|
|
832
|
-
const date = new Date(value);
|
|
833
|
-
if (isNaN(date.getTime())) {
|
|
834
|
-
return "Please enter a valid date";
|
|
835
|
-
}
|
|
836
|
-
return null;
|
|
837
|
-
},
|
|
838
|
-
match: (value, options = {}, translator, allValues = {}) => {
|
|
839
|
-
if (!value) return null;
|
|
840
|
-
const fieldName = options.field || options.fieldName;
|
|
841
|
-
if (value !== allValues[fieldName]) {
|
|
842
|
-
return options.message || `Must match ${fieldName}`;
|
|
843
|
-
}
|
|
844
|
-
return null;
|
|
845
|
-
},
|
|
846
|
-
custom: (value, options = {}, translator, allValues) => {
|
|
847
|
-
const validatorFn = options.validator || options.fn;
|
|
848
|
-
if (!validatorFn) return null;
|
|
849
|
-
const isValid = validatorFn(value, allValues);
|
|
850
|
-
return isValid ? null : options.message || "Validation failed";
|
|
851
|
-
},
|
|
852
|
-
fileType: (value, options = {}) => {
|
|
853
|
-
if (!value) return null;
|
|
854
|
-
const allowedTypes = options.accept || options.types || [];
|
|
855
|
-
if (value.type !== void 0) {
|
|
856
|
-
const fileType = value.type;
|
|
857
|
-
const fileExt = value.name ? value.name.split(".").pop().toLowerCase() : "";
|
|
858
|
-
const isValid = allowedTypes.some((type) => {
|
|
859
|
-
if (type.startsWith(".")) {
|
|
860
|
-
return fileExt === type.slice(1).toLowerCase();
|
|
861
|
-
}
|
|
862
|
-
if (type.includes("/")) {
|
|
863
|
-
if (type.endsWith("/*")) {
|
|
864
|
-
return fileType.startsWith(type.replace("/*", "/"));
|
|
865
|
-
}
|
|
866
|
-
return fileType === type;
|
|
867
|
-
}
|
|
868
|
-
return fileExt === type.toLowerCase();
|
|
869
|
-
});
|
|
870
|
-
if (!isValid) {
|
|
871
|
-
return options.message || `File type must be one of: ${allowedTypes.join(", ")}`;
|
|
872
|
-
}
|
|
873
|
-
return null;
|
|
874
|
-
}
|
|
875
|
-
return null;
|
|
876
|
-
},
|
|
877
|
-
fileSize: (value, options = {}) => {
|
|
878
|
-
if (!value) return null;
|
|
879
|
-
const maxSize = options.maxSize || Infinity;
|
|
880
|
-
if (value.size !== void 0) {
|
|
881
|
-
if (value.size > maxSize) {
|
|
882
|
-
const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(2);
|
|
883
|
-
return options.message || `File size must be less than ${maxSizeMB}MB`;
|
|
884
|
-
}
|
|
885
|
-
return null;
|
|
886
|
-
}
|
|
887
|
-
return null;
|
|
888
|
-
},
|
|
889
|
-
fileExtension: (value, options = {}) => {
|
|
890
|
-
if (!value) return null;
|
|
891
|
-
const allowedExtensions = options.extensions || [];
|
|
892
|
-
const fileName = value.name || value;
|
|
893
|
-
const ext = `.${fileName.split(".").pop().toLowerCase()}`;
|
|
894
|
-
const isValid = allowedExtensions.some((allowed) => {
|
|
895
|
-
return ext === allowed.toLowerCase();
|
|
896
|
-
});
|
|
897
|
-
if (!isValid) {
|
|
898
|
-
return options.message || `File extension must be one of: ${allowedExtensions.join(", ")}`;
|
|
899
|
-
}
|
|
900
|
-
return null;
|
|
901
|
-
},
|
|
902
|
-
alpha: (value) => {
|
|
903
|
-
if (!value) return null;
|
|
904
|
-
const alphaRegex = /^[a-zA-Z]+$/;
|
|
905
|
-
if (!alphaRegex.test(value)) {
|
|
906
|
-
return "Must contain only letters";
|
|
907
|
-
}
|
|
908
|
-
return null;
|
|
909
|
-
},
|
|
910
|
-
alphanumeric: (value) => {
|
|
911
|
-
if (!value) return null;
|
|
912
|
-
const alphanumericRegex = /^[a-zA-Z0-9]+$/;
|
|
913
|
-
if (!alphanumericRegex.test(value)) {
|
|
914
|
-
return "Must contain only letters and numbers";
|
|
915
|
-
}
|
|
916
|
-
return null;
|
|
917
|
-
},
|
|
918
|
-
uppercase: (value) => {
|
|
919
|
-
if (!value) return null;
|
|
920
|
-
if (value !== value.toUpperCase()) {
|
|
921
|
-
return "Must be uppercase";
|
|
922
|
-
}
|
|
923
|
-
return null;
|
|
924
|
-
},
|
|
925
|
-
// Get a registered validator
|
|
926
|
-
get: (name) => {
|
|
927
|
-
return validators2[name];
|
|
928
|
-
},
|
|
929
|
-
// Compose multiple validators
|
|
930
|
-
compose: (validatorList) => {
|
|
931
|
-
return (value, options, translator, allValues) => {
|
|
932
|
-
for (const validator of validatorList) {
|
|
933
|
-
const error = typeof validator === "function" ? validator(value, options, translator, allValues) : null;
|
|
934
|
-
if (error) {
|
|
935
|
-
return error;
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
return null;
|
|
939
|
-
};
|
|
940
|
-
},
|
|
941
|
-
// Debounce async validator
|
|
942
|
-
debounce: (validator, delay = 300) => {
|
|
943
|
-
let timeoutId;
|
|
944
|
-
return (value) => {
|
|
945
|
-
return new Promise((resolve) => {
|
|
946
|
-
clearTimeout(timeoutId);
|
|
947
|
-
timeoutId = setTimeout(async () => {
|
|
948
|
-
const result = await validator(value);
|
|
949
|
-
resolve(result);
|
|
950
|
-
}, delay);
|
|
951
|
-
});
|
|
952
|
-
};
|
|
953
|
-
},
|
|
954
|
-
// Cancellable async validator
|
|
955
|
-
cancellable: (validator) => {
|
|
956
|
-
let abortController;
|
|
957
|
-
const wrapped = async (value) => {
|
|
958
|
-
if (abortController) {
|
|
959
|
-
abortController.abort();
|
|
960
|
-
}
|
|
961
|
-
abortController = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
962
|
-
try {
|
|
963
|
-
return await validator(value, abortController ? abortController.signal : null);
|
|
964
|
-
} catch (error) {
|
|
965
|
-
if (error.name === "AbortError") {
|
|
966
|
-
return null;
|
|
967
|
-
}
|
|
968
|
-
throw error;
|
|
969
|
-
}
|
|
970
|
-
};
|
|
971
|
-
wrapped.cancel = () => {
|
|
972
|
-
if (abortController) {
|
|
973
|
-
abortController.abort();
|
|
974
|
-
}
|
|
975
|
-
};
|
|
976
|
-
return wrapped;
|
|
977
|
-
},
|
|
978
|
-
// Conditional validator
|
|
979
|
-
when: (condition, validator) => {
|
|
980
|
-
return (value, options = {}, translator, allValues = {}) => {
|
|
981
|
-
const context = options.min !== void 0 || options.max !== void 0 ? allValues : options;
|
|
982
|
-
const shouldValidate = typeof condition === "function" ? condition(value, context) : condition;
|
|
983
|
-
if (!shouldValidate) {
|
|
984
|
-
return null;
|
|
985
|
-
}
|
|
986
|
-
return typeof validator === "function" ? validator(value, options, translator, allValues) : null;
|
|
987
|
-
};
|
|
988
|
-
},
|
|
989
|
-
// Validator chain builder
|
|
990
|
-
chain: (options = {}) => {
|
|
991
|
-
const validatorList = [];
|
|
992
|
-
const stopOnFirstError = options.stopOnFirstError !== false;
|
|
993
|
-
const chain = {
|
|
994
|
-
required: (opts) => {
|
|
995
|
-
validatorList.push((v, o, t, a) => validators2.required(v, opts || o, t, a));
|
|
996
|
-
return chain;
|
|
997
|
-
},
|
|
998
|
-
email: (opts) => {
|
|
999
|
-
validatorList.push((v, o, t, a) => validators2.email(v, opts || o, t, a));
|
|
1000
|
-
return chain;
|
|
1001
|
-
},
|
|
1002
|
-
minLength: (opts) => {
|
|
1003
|
-
validatorList.push((v, o, t, a) => validators2.minLength(v, opts || o, t, a));
|
|
1004
|
-
return chain;
|
|
1005
|
-
},
|
|
1006
|
-
maxLength: (opts) => {
|
|
1007
|
-
validatorList.push((v, o, t, a) => validators2.maxLength(v, opts || o, t, a));
|
|
1008
|
-
return chain;
|
|
1009
|
-
},
|
|
1010
|
-
custom: (fn, message) => {
|
|
1011
|
-
validatorList.push((v, o, t, a) => {
|
|
1012
|
-
const result = fn(v, a);
|
|
1013
|
-
return result === null || result === true || result === void 0 ? null : message || result;
|
|
1014
|
-
});
|
|
1015
|
-
return chain;
|
|
1016
|
-
},
|
|
1017
|
-
validate: (value, opts, translator, allValues) => {
|
|
1018
|
-
if (stopOnFirstError) {
|
|
1019
|
-
for (const validator of validatorList) {
|
|
1020
|
-
const error = validator(value, opts, translator, allValues);
|
|
1021
|
-
if (error) {
|
|
1022
|
-
return error;
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
return null;
|
|
1026
|
-
} else {
|
|
1027
|
-
const errors = [];
|
|
1028
|
-
for (const validator of validatorList) {
|
|
1029
|
-
const error = validator(value, opts, translator, allValues);
|
|
1030
|
-
if (error) {
|
|
1031
|
-
errors.push(error);
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
return errors.length > 0 ? errors : null;
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
};
|
|
1038
|
-
return chain;
|
|
1039
|
-
}
|
|
1040
|
-
};
|
|
1041
|
-
function validateField(value, validatorList, formData = {}) {
|
|
1042
|
-
for (const validator of validatorList) {
|
|
1043
|
-
const error = validator(value, formData);
|
|
1044
|
-
if (error) {
|
|
1045
|
-
return error;
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
return null;
|
|
1049
|
-
}
|
|
1050
|
-
function validateForm(formData, fieldValidators) {
|
|
1051
|
-
const errors = {};
|
|
1052
|
-
for (const [fieldName, validatorList] of Object.entries(fieldValidators)) {
|
|
1053
|
-
const value = formData[fieldName];
|
|
1054
|
-
const error = validateField(value, validatorList, formData);
|
|
1055
|
-
if (error) {
|
|
1056
|
-
errors[fieldName] = error;
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
return Object.keys(errors).length > 0 ? errors : null;
|
|
1060
|
-
}
|
|
1061
|
-
function registerValidator(name, validatorFn) {
|
|
1062
|
-
validators2[name] = validatorFn;
|
|
1063
|
-
validators[name] = validatorFn;
|
|
1064
|
-
}
|
|
1065
|
-
function composeValidators(...validatorFns) {
|
|
1066
|
-
return (value, options, translator, allValues) => {
|
|
1067
|
-
for (const validator of validatorFns) {
|
|
1068
|
-
const error = validator(value, options, translator, allValues);
|
|
1069
|
-
if (error) {
|
|
1070
|
-
return error;
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
return null;
|
|
1074
|
-
};
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
// src/form-hydration.js
|
|
1078
|
-
function hydrateForm(formSelector, options = {}) {
|
|
1079
|
-
if (typeof document === "undefined") {
|
|
1080
|
-
console.warn("hydrateForm can only run in browser environment");
|
|
1081
|
-
return null;
|
|
1082
|
-
}
|
|
1083
|
-
const form = typeof formSelector === "string" ? document.querySelector(formSelector) : formSelector;
|
|
1084
|
-
if (!form) {
|
|
1085
|
-
console.warn(`Form not found: ${formSelector}`);
|
|
1109
|
+
const form = typeof formSelector === "string" ? document.querySelector(formSelector) : formSelector;
|
|
1110
|
+
if (!form) {
|
|
1111
|
+
console.warn(`Form not found: ${formSelector}`);
|
|
1086
1112
|
return null;
|
|
1087
1113
|
}
|
|
1088
1114
|
const opts = {
|
|
@@ -1108,17 +1134,6 @@ function hydrateForm(formSelector, options = {}) {
|
|
|
1108
1134
|
fields: /* @__PURE__ */ new Map()
|
|
1109
1135
|
};
|
|
1110
1136
|
const debounceTimers = /* @__PURE__ */ new Map();
|
|
1111
|
-
function parseValidators(validatorString) {
|
|
1112
|
-
if (!validatorString) return [];
|
|
1113
|
-
return validatorString.split(",").map((v) => {
|
|
1114
|
-
const trimmed = v.trim();
|
|
1115
|
-
const [name, ...params] = trimmed.split(":");
|
|
1116
|
-
if (validators2[name]) {
|
|
1117
|
-
return params.length > 0 ? validators2[name](...params.map((p) => isNaN(p) ? p : Number(p))) : validators2[name];
|
|
1118
|
-
}
|
|
1119
|
-
return null;
|
|
1120
|
-
}).filter(Boolean);
|
|
1121
|
-
}
|
|
1122
1137
|
function discoverFields() {
|
|
1123
1138
|
const inputs = form.querySelectorAll("[name]");
|
|
1124
1139
|
inputs.forEach((input) => {
|
|
@@ -1341,6 +1356,111 @@ function hydrateForm(formSelector, options = {}) {
|
|
|
1341
1356
|
})
|
|
1342
1357
|
};
|
|
1343
1358
|
}
|
|
1359
|
+
|
|
1360
|
+
// src/validation.js
|
|
1361
|
+
var FormValidator = class {
|
|
1362
|
+
constructor(schema = {}) {
|
|
1363
|
+
this.schema = schema;
|
|
1364
|
+
this.errors = {};
|
|
1365
|
+
this.touched = {};
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Validate a single field
|
|
1369
|
+
*/
|
|
1370
|
+
validateField(name, value, formData = {}) {
|
|
1371
|
+
const fieldValidators = this.schema[name];
|
|
1372
|
+
if (!fieldValidators) {
|
|
1373
|
+
return null;
|
|
1374
|
+
}
|
|
1375
|
+
const validatorArray = Array.isArray(fieldValidators) ? fieldValidators : [fieldValidators];
|
|
1376
|
+
for (const entry of validatorArray) {
|
|
1377
|
+
const validator = resolveValidator(entry);
|
|
1378
|
+
const error = validator ? validator(value, formData) : null;
|
|
1379
|
+
if (error) {
|
|
1380
|
+
return error;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Validate entire form
|
|
1387
|
+
*/
|
|
1388
|
+
validate(formData) {
|
|
1389
|
+
const errors = {};
|
|
1390
|
+
let isValid = true;
|
|
1391
|
+
for (const [name, value] of Object.entries(formData)) {
|
|
1392
|
+
const error = this.validateField(name, value, formData);
|
|
1393
|
+
if (error) {
|
|
1394
|
+
errors[name] = error;
|
|
1395
|
+
isValid = false;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
for (const name of Object.keys(this.schema)) {
|
|
1399
|
+
if (!(name in formData)) {
|
|
1400
|
+
const error = this.validateField(name, void 0, formData);
|
|
1401
|
+
if (error) {
|
|
1402
|
+
errors[name] = error;
|
|
1403
|
+
isValid = false;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
this.errors = errors;
|
|
1408
|
+
return { isValid, errors };
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Mark field as touched
|
|
1412
|
+
*/
|
|
1413
|
+
touch(name) {
|
|
1414
|
+
this.touched[name] = true;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Check if field is touched
|
|
1418
|
+
*/
|
|
1419
|
+
isTouched(name) {
|
|
1420
|
+
return this.touched[name] || false;
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Get error for field
|
|
1424
|
+
*/
|
|
1425
|
+
getError(name) {
|
|
1426
|
+
return this.errors[name] || null;
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Check if field has error
|
|
1430
|
+
*/
|
|
1431
|
+
hasError(name) {
|
|
1432
|
+
return !!this.errors[name];
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Clear errors
|
|
1436
|
+
*/
|
|
1437
|
+
clearErrors() {
|
|
1438
|
+
this.errors = {};
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Clear touched state
|
|
1442
|
+
*/
|
|
1443
|
+
clearTouched() {
|
|
1444
|
+
this.touched = {};
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Reset validator
|
|
1448
|
+
*/
|
|
1449
|
+
reset() {
|
|
1450
|
+
this.clearErrors();
|
|
1451
|
+
this.clearTouched();
|
|
1452
|
+
}
|
|
1453
|
+
};
|
|
1454
|
+
function createValidator(schemaOrFn, message) {
|
|
1455
|
+
if (typeof schemaOrFn === "function") {
|
|
1456
|
+
return wrapValidator(schemaOrFn, message);
|
|
1457
|
+
}
|
|
1458
|
+
return new FormValidator(schemaOrFn);
|
|
1459
|
+
}
|
|
1460
|
+
function validate(formData, schema) {
|
|
1461
|
+
const validator = new FormValidator(schema);
|
|
1462
|
+
return validator.validate(formData);
|
|
1463
|
+
}
|
|
1344
1464
|
export {
|
|
1345
1465
|
DEFAULT_CLASS_NAMES,
|
|
1346
1466
|
FormBuilder,
|