@coherent.js/forms 1.1.2 → 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.
@@ -5,199 +5,245 @@ function isEmailShaped(value) {
5
5
  return typeof value === "string" && value.length <= EMAIL_MAX_LENGTH && EMAIL_PATTERN.test(value);
6
6
  }
7
7
 
8
- // src/validators.js
9
- var validators = {
10
- required: (value, options = {}) => {
11
- if (value === null || value === void 0 || value === "") {
12
- return options.message || validators.required.message || "This field is required";
13
- }
14
- return null;
15
- },
16
- email: (value) => {
17
- if (!value) return null;
18
- if (!isEmailShaped(value)) {
19
- return "Please enter a valid email address";
20
- }
21
- return null;
22
- },
23
- minLength: (value, options = {}) => {
24
- if (!value) return null;
25
- const min = options.min || 0;
26
- if (value.length < min) {
27
- return options.message || `Must be at least ${min} characters`;
28
- }
29
- return null;
30
- },
31
- maxLength: (value, options = {}) => {
32
- if (!value) return null;
33
- const max = options.max || Infinity;
34
- if (value.length > max) {
35
- return options.message || `Must be no more than ${max} characters`;
36
- }
37
- return null;
38
- },
39
- min: (value, options = {}) => {
40
- if (value === null || value === void 0 || value === "") return null;
41
- const num = Number(value);
42
- const minValue = options.min || 0;
43
- if (isNaN(num) || num < minValue) {
44
- return options.message || `Must be at least ${minValue}`;
45
- }
46
- return null;
47
- },
48
- max: (value, options = {}) => {
49
- if (value === null || value === void 0 || value === "") return null;
50
- const num = Number(value);
51
- const maxValue = options.max || Infinity;
52
- if (isNaN(num) || num > maxValue) {
53
- return options.message || `Must be no more than ${maxValue}`;
54
- }
55
- return null;
56
- },
57
- pattern: (value, options = {}) => {
58
- if (!value) return null;
59
- const regex = options.pattern || options.regex;
60
- if (regex && !regex.test(value)) {
61
- return options.message || "Invalid format";
62
- }
63
- return null;
64
- },
65
- url: (value) => {
66
- if (!value) return null;
8
+ // src/rules.js
9
+ var BUILTIN = /* @__PURE__ */ Symbol("coherent.forms.builtin");
10
+ var DESCRIPTORS = /* @__PURE__ */ new WeakMap();
11
+ var HELPER_NAMES = /* @__PURE__ */ new Set(["get", "compose", "debounce", "cancellable", "when", "chain"]);
12
+ var isEmpty = (value) => value === null || value === void 0 || value === "";
13
+ var isOptionsObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof RegExp);
14
+ function argsFromString(params, kind, text) {
15
+ if (text === void 0 || text.trim() === "") return [];
16
+ if (params.length === 0) return [text.trim()];
17
+ if (kind === "list") {
18
+ return [text.split(",").map((part) => part.trim()).filter(Boolean)];
19
+ }
20
+ if (kind === "regexp") {
67
21
  try {
68
- new URL(value);
69
- return null;
22
+ return [new RegExp(text)];
70
23
  } catch {
71
- return "Please enter a valid URL";
72
- }
73
- },
74
- number: (value) => {
75
- if (value === null || value === void 0 || value === "") return null;
76
- if (isNaN(Number(value))) {
77
- return "Must be a valid number";
78
- }
79
- return null;
80
- },
81
- integer: (value) => {
82
- if (value === null || value === void 0 || value === "") return null;
83
- const num = Number(value);
84
- if (isNaN(num) || !Number.isInteger(num)) {
85
- return "Must be a whole number";
86
- }
87
- return null;
88
- },
89
- phone: (value) => {
90
- if (!value) return null;
91
- const phoneRegex = /^[\d\s\-\+\(\)]+$/;
92
- if (!phoneRegex.test(value) || value.replace(/\D/g, "").length < 10) {
93
- return "Please enter a valid phone number";
94
- }
95
- return null;
96
- },
97
- date: (value) => {
98
- if (!value) return null;
99
- const date = new Date(value);
100
- if (isNaN(date.getTime())) {
101
- return "Please enter a valid date";
102
- }
103
- return null;
104
- },
105
- match: (value, options = {}, translator, allValues = {}) => {
106
- if (!value) return null;
107
- const fieldName = options.field || options.fieldName;
108
- if (value !== allValues[fieldName]) {
109
- return options.message || `Must match ${fieldName}`;
110
- }
111
- return null;
112
- },
113
- custom: (value, options = {}, translator, allValues) => {
114
- const validatorFn = options.validator || options.fn;
115
- if (!validatorFn) return null;
116
- const isValid = validatorFn(value, allValues);
117
- return isValid ? null : options.message || "Validation failed";
118
- },
119
- fileType: (value, options = {}) => {
120
- if (!value) return null;
121
- const allowedTypes = options.accept || options.types || [];
122
- if (value.type !== void 0) {
123
- const fileType = value.type;
124
- const fileExt = value.name ? value.name.split(".").pop().toLowerCase() : "";
125
- const isValid = allowedTypes.some((type) => {
126
- if (type.startsWith(".")) {
127
- return fileExt === type.slice(1).toLowerCase();
128
- }
129
- if (type.includes("/")) {
130
- if (type.endsWith("/*")) {
131
- return fileType.startsWith(type.replace("/*", "/"));
132
- }
133
- return fileType === type;
134
- }
135
- return fileExt === type.toLowerCase();
136
- });
137
- if (!isValid) {
138
- return options.message || `File type must be one of: ${allowedTypes.join(", ")}`;
139
- }
140
24
  return null;
141
25
  }
142
- return null;
143
- },
144
- fileSize: (value, options = {}) => {
145
- if (!value) return null;
146
- const maxSize = options.maxSize || Infinity;
147
- if (value.size !== void 0) {
148
- if (value.size > maxSize) {
149
- const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(2);
150
- return options.message || `File size must be less than ${maxSizeMB}MB`;
151
- }
152
- return null;
26
+ }
27
+ const comma = text.indexOf(",");
28
+ const raw = (comma === -1 ? text : text.slice(0, comma)).trim();
29
+ const message = comma === -1 ? "" : text.slice(comma + 1).trim();
30
+ let arg = raw;
31
+ if (kind === "number") {
32
+ arg = Number(raw);
33
+ if (raw === "" || Number.isNaN(arg)) return null;
34
+ }
35
+ return message ? [arg, message] : [arg];
36
+ }
37
+ function defineRule(name, { params = [], stringArg = "string", valid, message }) {
38
+ const fromOptions = (options = {}) => {
39
+ const config = { message: options.message };
40
+ for (const [key, ...aliases] of params) {
41
+ config[key] = [key, ...aliases].map((alias) => options[alias]).find((v) => v !== void 0);
153
42
  }
154
- return null;
155
- },
156
- fileExtension: (value, options = {}) => {
157
- if (!value) return null;
158
- const allowedExtensions = options.extensions || [];
159
- const fileName = value.name || value;
160
- const ext = `.${fileName.split(".").pop().toLowerCase()}`;
161
- const isValid = allowedExtensions.some((allowed) => {
162
- return ext === allowed.toLowerCase();
43
+ return config;
44
+ };
45
+ const check = (value, config, formData) => {
46
+ if (valid(value, config, formData || {})) return null;
47
+ return config.message || builtin.message || message(config);
48
+ };
49
+ const factory = (...args) => {
50
+ const config = { message: args[params.length] };
51
+ params.forEach(([key], index) => {
52
+ config[key] = args[index];
163
53
  });
164
- if (!isValid) {
165
- return options.message || `File extension must be one of: ${allowedExtensions.join(", ")}`;
54
+ const rule = (value, formData, _translator, allValues) => check(value, config, allValues ?? formData);
55
+ DESCRIPTORS.set(rule, { name, args });
56
+ return rule;
57
+ };
58
+ const isDirectCall = (args) => {
59
+ if (args.length >= 2) {
60
+ return params.length === 0 || isOptionsObject(args[1]);
166
61
  }
167
- return null;
168
- },
169
- alpha: (value) => {
170
- if (!value) return null;
171
- const alphaRegex = /^[a-zA-Z]+$/;
172
- if (!alphaRegex.test(value)) {
173
- return "Must contain only letters";
62
+ if (args.length === 1 && params.length === 0) {
63
+ const [arg] = args;
64
+ return arg === "" || arg !== void 0 && arg !== null && typeof arg !== "string";
174
65
  }
175
- return null;
176
- },
177
- alphanumeric: (value) => {
178
- if (!value) return null;
179
- const alphanumericRegex = /^[a-zA-Z0-9]+$/;
180
- if (!alphanumericRegex.test(value)) {
181
- return "Must contain only letters and numbers";
66
+ return false;
67
+ };
68
+ function builtin(...args) {
69
+ if (isDirectCall(args)) {
70
+ const [value, options, , allValues] = args;
71
+ return check(value, fromOptions(isOptionsObject(options) ? options : {}), allValues);
182
72
  }
183
- return null;
184
- },
185
- uppercase: (value) => {
186
- if (!value) return null;
187
- if (value !== value.toUpperCase()) {
188
- return "Must be uppercase";
73
+ return factory(...args);
74
+ }
75
+ Object.defineProperty(builtin, "name", { value: name });
76
+ builtin[BUILTIN] = {
77
+ name,
78
+ factory,
79
+ argsFromString: (text) => argsFromString(params, stringArg, text)
80
+ };
81
+ return builtin;
82
+ }
83
+ var toNumber = (value) => typeof value === "number" ? value : Number(value);
84
+ function testRegExp(regex, value) {
85
+ if (!(regex instanceof RegExp)) return true;
86
+ regex.lastIndex = 0;
87
+ return regex.test(String(value));
88
+ }
89
+ function fileMatchesType(file, allowedTypes) {
90
+ const fileType = file.type;
91
+ const fileExt = file.name ? file.name.split(".").pop().toLowerCase() : "";
92
+ return allowedTypes.some((type) => {
93
+ if (type.startsWith(".")) {
94
+ return fileExt === type.slice(1).toLowerCase();
95
+ }
96
+ if (type.includes("/")) {
97
+ if (type.endsWith("/*")) {
98
+ return fileType.startsWith(type.replace("/*", "/"));
99
+ }
100
+ return fileType === type;
189
101
  }
190
- return null;
191
- },
192
- // Get a registered validator
193
- get: (name) => {
194
- return validators[name];
195
- },
196
- // Compose multiple validators
102
+ return fileExt === type.toLowerCase();
103
+ });
104
+ }
105
+ var validators = {
106
+ required: defineRule("required", {
107
+ valid: (value) => !isEmpty(value),
108
+ message: () => "This field is required"
109
+ }),
110
+ email: defineRule("email", {
111
+ valid: (value) => isEmpty(value) || isEmailShaped(value),
112
+ message: () => "Invalid email address"
113
+ }),
114
+ url: defineRule("url", {
115
+ valid: (value) => {
116
+ if (isEmpty(value)) return true;
117
+ try {
118
+ new URL(value);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ },
124
+ message: () => "Invalid URL"
125
+ }),
126
+ minLength: defineRule("minLength", {
127
+ params: [["min", "minLength"]],
128
+ stringArg: "number",
129
+ valid: (value, { min }) => !value || !(value.length < (min ?? 0)),
130
+ message: ({ min }) => `Minimum length is ${min}`
131
+ }),
132
+ maxLength: defineRule("maxLength", {
133
+ params: [["max", "maxLength"]],
134
+ stringArg: "number",
135
+ valid: (value, { max }) => !value || !(value.length > (max ?? Infinity)),
136
+ message: ({ max }) => `Maximum length is ${max}`
137
+ }),
138
+ // Empty values pass (combine with `required`); a non-numeric value fails.
139
+ min: defineRule("min", {
140
+ params: [["min"]],
141
+ stringArg: "number",
142
+ valid: (value, { min }) => {
143
+ if (isEmpty(value)) return true;
144
+ const number = toNumber(value);
145
+ return !Number.isNaN(number) && !(number < (min ?? -Infinity));
146
+ },
147
+ message: ({ min }) => `Minimum value is ${min}`
148
+ }),
149
+ max: defineRule("max", {
150
+ params: [["max"]],
151
+ stringArg: "number",
152
+ valid: (value, { max }) => {
153
+ if (isEmpty(value)) return true;
154
+ const number = toNumber(value);
155
+ return !Number.isNaN(number) && !(number > (max ?? Infinity));
156
+ },
157
+ message: ({ max }) => `Maximum value is ${max}`
158
+ }),
159
+ pattern: defineRule("pattern", {
160
+ params: [["pattern", "regex"]],
161
+ stringArg: "regexp",
162
+ valid: (value, { pattern }) => isEmpty(value) || testRegExp(pattern, value),
163
+ message: () => "Invalid format"
164
+ }),
165
+ /** Equal to another field, even when empty */
166
+ matches: defineRule("matches", {
167
+ params: [["field", "fieldName"]],
168
+ valid: (value, { field }, formData) => value === formData[field],
169
+ message: () => "Fields do not match"
170
+ }),
171
+ /** Equal to another field; an empty value passes */
172
+ match: defineRule("match", {
173
+ params: [["field", "fieldName"]],
174
+ valid: (value, { field }, formData) => !value || value === formData[field],
175
+ message: ({ field }) => `Must match ${field}`
176
+ }),
177
+ oneOf: defineRule("oneOf", {
178
+ params: [["options", "values"]],
179
+ stringArg: "list",
180
+ valid: (value, { options }) => !value || !Array.isArray(options) || options.includes(value),
181
+ message: () => "Invalid option"
182
+ }),
183
+ custom: defineRule("custom", {
184
+ params: [["validator", "fn"]],
185
+ valid: (value, { validator }, formData) => typeof validator !== "function" || Boolean(validator(value, formData)),
186
+ message: () => "Validation failed"
187
+ }),
188
+ number: defineRule("number", {
189
+ valid: (value) => isEmpty(value) || !Number.isNaN(Number(value)),
190
+ message: () => "Must be a valid number"
191
+ }),
192
+ integer: defineRule("integer", {
193
+ valid: (value) => isEmpty(value) || Number.isInteger(Number(value)),
194
+ message: () => "Must be a whole number"
195
+ }),
196
+ phone: defineRule("phone", {
197
+ valid: (value) => !value || /^[\d\s\-+()]+$/.test(value) && value.replace(/\D/g, "").length >= 10,
198
+ message: () => "Please enter a valid phone number"
199
+ }),
200
+ date: defineRule("date", {
201
+ valid: (value) => !value || !Number.isNaN(new Date(value).getTime()),
202
+ message: () => "Please enter a valid date"
203
+ }),
204
+ alpha: defineRule("alpha", {
205
+ valid: (value) => !value || /^[a-zA-Z]+$/.test(value),
206
+ message: () => "Must contain only letters"
207
+ }),
208
+ alphanumeric: defineRule("alphanumeric", {
209
+ valid: (value) => !value || /^[a-zA-Z0-9]+$/.test(value),
210
+ message: () => "Must contain only letters and numbers"
211
+ }),
212
+ uppercase: defineRule("uppercase", {
213
+ valid: (value) => !value || value === String(value).toUpperCase(),
214
+ message: () => "Must be uppercase"
215
+ }),
216
+ fileType: defineRule("fileType", {
217
+ params: [["accept", "types"]],
218
+ stringArg: "list",
219
+ valid: (value, { accept }) => !value || value.type === void 0 || fileMatchesType(value, accept || []),
220
+ message: ({ accept }) => `File type must be one of: ${(accept || []).join(", ")}`
221
+ }),
222
+ fileSize: defineRule("fileSize", {
223
+ params: [["maxSize"]],
224
+ stringArg: "number",
225
+ valid: (value, { maxSize }) => !value || value.size === void 0 || !(value.size > (maxSize ?? Infinity)),
226
+ message: ({ maxSize }) => `File size must be less than ${((maxSize ?? Infinity) / (1024 * 1024)).toFixed(2)}MB`
227
+ }),
228
+ fileExtension: defineRule("fileExtension", {
229
+ params: [["extensions"]],
230
+ stringArg: "list",
231
+ valid: (value, { extensions }) => {
232
+ if (!value) return true;
233
+ const fileName = value.name || value;
234
+ const ext = `.${String(fileName).split(".").pop().toLowerCase()}`;
235
+ return (extensions || []).some((allowed) => ext === allowed.toLowerCase());
236
+ },
237
+ message: ({ extensions }) => `File extension must be one of: ${(extensions || []).join(", ")}`
238
+ }),
239
+ /** A registered validator or built-in by name */
240
+ get: (name) => Object.hasOwn(validators, name) ? validators[name] : void 0,
241
+ /** Combine validators into one returning the first error */
197
242
  compose: (validatorList) => {
243
+ const rules = validatorList.map(resolveValidator);
198
244
  return (value, options, translator, allValues) => {
199
- for (const validator of validatorList) {
200
- const error = typeof validator === "function" ? validator(value, options, translator, allValues) : null;
245
+ for (const rule of rules) {
246
+ const error = rule ? rule(value, options, translator, allValues) : null;
201
247
  if (error) {
202
248
  return error;
203
249
  }
@@ -205,7 +251,7 @@ var validators = {
205
251
  return null;
206
252
  };
207
253
  },
208
- // Debounce async validator
254
+ /** Debounce an async validator */
209
255
  debounce: (validator, delay = 300) => {
210
256
  let timeoutId;
211
257
  return (value) => {
@@ -218,7 +264,7 @@ var validators = {
218
264
  });
219
265
  };
220
266
  },
221
- // Cancellable async validator
267
+ /** Cancellable async validator */
222
268
  cancellable: (validator) => {
223
269
  let abortController;
224
270
  const wrapped = async (value) => {
@@ -242,38 +288,31 @@ var validators = {
242
288
  };
243
289
  return wrapped;
244
290
  },
245
- // Conditional validator
291
+ /** Run `validator` only when `condition` holds */
246
292
  when: (condition, validator) => {
293
+ const rule = resolveValidator(validator);
247
294
  return (value, options = {}, translator, allValues = {}) => {
248
295
  const context = options.min !== void 0 || options.max !== void 0 ? allValues : options;
249
296
  const shouldValidate = typeof condition === "function" ? condition(value, context) : condition;
250
297
  if (!shouldValidate) {
251
298
  return null;
252
299
  }
253
- return typeof validator === "function" ? validator(value, options, translator, allValues) : null;
300
+ return rule ? rule(value, options, translator, allValues) : null;
254
301
  };
255
302
  },
256
- // Validator chain builder
303
+ /** Validator chain builder */
257
304
  chain: (options = {}) => {
258
305
  const validatorList = [];
259
306
  const stopOnFirstError = options.stopOnFirstError !== false;
307
+ const direct = (name) => (opts) => {
308
+ validatorList.push((v, o, t, a) => validators[name](v, opts || o || {}, t, a));
309
+ return chain;
310
+ };
260
311
  const chain = {
261
- required: (opts) => {
262
- validatorList.push((v, o, t, a) => validators.required(v, opts || o, t, a));
263
- return chain;
264
- },
265
- email: (opts) => {
266
- validatorList.push((v, o, t, a) => validators.email(v, opts || o, t, a));
267
- return chain;
268
- },
269
- minLength: (opts) => {
270
- validatorList.push((v, o, t, a) => validators.minLength(v, opts || o, t, a));
271
- return chain;
272
- },
273
- maxLength: (opts) => {
274
- validatorList.push((v, o, t, a) => validators.maxLength(v, opts || o, t, a));
275
- return chain;
276
- },
312
+ required: direct("required"),
313
+ email: direct("email"),
314
+ minLength: direct("minLength"),
315
+ maxLength: direct("maxLength"),
277
316
  custom: (fn, message) => {
278
317
  validatorList.push((v, o, t, a) => {
279
318
  const result = fn(v, a);
@@ -290,21 +329,82 @@ var validators = {
290
329
  }
291
330
  }
292
331
  return null;
293
- } else {
294
- const errors = [];
295
- for (const validator of validatorList) {
296
- const error = validator(value, opts, translator, allValues);
297
- if (error) {
298
- errors.push(error);
299
- }
332
+ }
333
+ const errors = [];
334
+ for (const validator of validatorList) {
335
+ const error = validator(value, opts, translator, allValues);
336
+ if (error) {
337
+ errors.push(error);
300
338
  }
301
- return errors.length > 0 ? errors : null;
302
339
  }
340
+ return errors.length > 0 ? errors : null;
303
341
  }
304
342
  };
305
343
  return chain;
306
344
  }
307
345
  };
346
+ function lookup(name) {
347
+ const found = Object.hasOwn(validators, name) && !HELPER_NAMES.has(name) ? validators[name] : null;
348
+ return typeof found === "function" ? found : null;
349
+ }
350
+ function resolveString(entry) {
351
+ const exact = lookup(entry);
352
+ const colon = exact ? -1 : entry.indexOf(":");
353
+ const name = colon === -1 ? entry : entry.slice(0, colon).trim();
354
+ const found = exact ?? lookup(name);
355
+ if (!found) return null;
356
+ const text = colon === -1 ? void 0 : entry.slice(colon + 1);
357
+ if (!found[BUILTIN]) {
358
+ return text === void 0 ? { name, args: [], validator: found } : null;
359
+ }
360
+ const args = found[BUILTIN].argsFromString(text);
361
+ if (!args) return null;
362
+ return { name: found[BUILTIN].name, args, validator: found[BUILTIN].factory(...args) };
363
+ }
364
+ function resolveValidator(entry) {
365
+ if (typeof entry === "string") {
366
+ return resolveString(entry)?.validator ?? null;
367
+ }
368
+ if (typeof entry !== "function") return null;
369
+ return entry[BUILTIN] ? entry[BUILTIN].factory() : entry;
370
+ }
371
+ function reviveArg(arg) {
372
+ if (isOptionsObject(arg) && Array.isArray(arg.$regexp)) {
373
+ try {
374
+ return new RegExp(arg.$regexp[0], arg.$regexp[1]);
375
+ } catch {
376
+ return void 0;
377
+ }
378
+ }
379
+ return arg;
380
+ }
381
+ function parseValidators(attribute) {
382
+ if (!attribute) return [];
383
+ const text = String(attribute).trim();
384
+ let specs;
385
+ if (text.startsWith("[")) {
386
+ try {
387
+ specs = JSON.parse(text);
388
+ } catch {
389
+ return [];
390
+ }
391
+ if (!Array.isArray(specs)) return [];
392
+ } else {
393
+ specs = text.split(",").map((part) => {
394
+ const [name, ...params] = part.trim().split(":");
395
+ return { name, args: params.map((p) => p !== "" && !Number.isNaN(Number(p)) ? Number(p) : p) };
396
+ });
397
+ }
398
+ return specs.map((spec) => {
399
+ if (!spec || typeof spec.name !== "string") return null;
400
+ const { name } = spec;
401
+ if (!Object.hasOwn(validators, name) || HELPER_NAMES.has(name)) return null;
402
+ const entry = validators[name];
403
+ if (typeof entry !== "function") return null;
404
+ const args = Array.isArray(spec.args) ? spec.args.map(reviveArg) : [];
405
+ return entry[BUILTIN] ? entry[BUILTIN].factory(...args) : entry;
406
+ }).filter(Boolean);
407
+ }
308
408
 
309
409
  // src/form-builder.js
310
410
  import { render as renderToHTML } from "@coherent.js/core";
@@ -355,17 +455,6 @@ function hydrateForm(formSelector, options = {}) {
355
455
  fields: /* @__PURE__ */ new Map()
356
456
  };
357
457
  const debounceTimers = /* @__PURE__ */ new Map();
358
- function parseValidators(validatorString) {
359
- if (!validatorString) return [];
360
- return validatorString.split(",").map((v) => {
361
- const trimmed = v.trim();
362
- const [name, ...params] = trimmed.split(":");
363
- if (validators[name]) {
364
- return params.length > 0 ? validators[name](...params.map((p) => isNaN(p) ? p : Number(p))) : validators[name];
365
- }
366
- return null;
367
- }).filter(Boolean);
368
- }
369
458
  function discoverFields() {
370
459
  const inputs = form.querySelectorAll("[name]");
371
460
  inputs.forEach((input) => {