@orkestrel/form 0.0.1
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 +21 -0
- package/README.md +80 -0
- package/dist/src/core/index.cjs +1758 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +1160 -0
- package/dist/src/core/index.d.ts +1160 -0
- package/dist/src/core/index.js +1704 -0
- package/dist/src/core/index.js.map +1 -0
- package/package.json +80 -0
|
@@ -0,0 +1,1704 @@
|
|
|
1
|
+
import { arrayOf, attempt, cloneJSONRecord, isArray, isBoolean, isBoundedJSONRecord, isContractError, isFiniteNumber, isFunction, isInteger, isRecord, isString, literalOf, parseNumber, readArrayEntries, recordOf, unionOf } from "@orkestrel/contract";
|
|
2
|
+
import { Emitter } from "@orkestrel/emitter";
|
|
3
|
+
//#region src/core/constants.ts
|
|
4
|
+
/** Every field control, in the order declared by the public contract. */
|
|
5
|
+
var FIELD_CONTROLS = Object.freeze([
|
|
6
|
+
"text",
|
|
7
|
+
"editor",
|
|
8
|
+
"password",
|
|
9
|
+
"number",
|
|
10
|
+
"date",
|
|
11
|
+
"time",
|
|
12
|
+
"datetime",
|
|
13
|
+
"color",
|
|
14
|
+
"confirm",
|
|
15
|
+
"select",
|
|
16
|
+
"checkbox",
|
|
17
|
+
"file"
|
|
18
|
+
]);
|
|
19
|
+
/** Every form lifecycle status. */
|
|
20
|
+
var FORM_STATUSES = Object.freeze([
|
|
21
|
+
"editing",
|
|
22
|
+
"settled",
|
|
23
|
+
"abandoned"
|
|
24
|
+
]);
|
|
25
|
+
/** Default failure copy for every named field rule. */
|
|
26
|
+
var RULE_MESSAGES = Object.freeze({
|
|
27
|
+
required: "This field is required",
|
|
28
|
+
minimum: "Must be at least {limit}",
|
|
29
|
+
maximum: "Must be at most {limit}",
|
|
30
|
+
step: "Must be a multiple of {limit}",
|
|
31
|
+
pattern: "Must match the required format",
|
|
32
|
+
email: "Must be a valid email address",
|
|
33
|
+
url: "Must be a valid URL",
|
|
34
|
+
integer: "Must be an integer",
|
|
35
|
+
alphanumeric: "Must contain only letters and numbers"
|
|
36
|
+
});
|
|
37
|
+
/** A practical whole-address email shape. */
|
|
38
|
+
var EMAIL_PATTERN = Object.freeze(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
|
|
39
|
+
/** An absolute HTTP or HTTPS URL shape. */
|
|
40
|
+
var URL_PATTERN = Object.freeze(/^https?:\/\/[^\s]+$/);
|
|
41
|
+
/** One or more ASCII letters or digits. */
|
|
42
|
+
var ALPHANUMERIC_PATTERN = Object.freeze(/^[A-Za-z0-9]+$/);
|
|
43
|
+
/** A signed or unsigned base-ten integer string. */
|
|
44
|
+
var INTEGER_PATTERN = Object.freeze(/^[+-]?\d+$/);
|
|
45
|
+
/** A six-digit hexadecimal color string. */
|
|
46
|
+
var COLOR_PATTERN = Object.freeze(/^#[0-9A-Fa-f]{6}$/);
|
|
47
|
+
/** An ISO calendar date string in `YYYY-MM-DD` form. */
|
|
48
|
+
var DATE_PATTERN = Object.freeze(/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/);
|
|
49
|
+
/** A 24-hour time string with optional seconds. */
|
|
50
|
+
var TIME_PATTERN = Object.freeze(/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/);
|
|
51
|
+
/** An ISO local date and time string with optional seconds. */
|
|
52
|
+
var DATETIME_PATTERN = Object.freeze(/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/);
|
|
53
|
+
/** The maximum accepted source length for an authored regular expression. */
|
|
54
|
+
var PATTERN_LIMIT = 256;
|
|
55
|
+
/** The maximum number of fields one schema may declare. */
|
|
56
|
+
var FIELD_LIMIT = 512;
|
|
57
|
+
/** The maximum number of groups one schema may declare. */
|
|
58
|
+
var GROUP_LIMIT = 64;
|
|
59
|
+
/** The maximum number of choices one `select` or `checkbox` field may offer. */
|
|
60
|
+
var CHOICE_LIMIT = 1024;
|
|
61
|
+
/** The maximum number of entries one list-valued answer may hold. */
|
|
62
|
+
var LIST_LIMIT = 1024;
|
|
63
|
+
/** The maximum length, in UTF-16 code units, of a schema, group, or field name. */
|
|
64
|
+
var NAME_LIMIT = 128;
|
|
65
|
+
/** The maximum length, in UTF-16 code units, of any single retained string. */
|
|
66
|
+
var STRING_LIMIT = 65536;
|
|
67
|
+
/** The maximum total length, in UTF-16 code units, of every string one schema retains. */
|
|
68
|
+
var TEXT_LIMIT = 1048576;
|
|
69
|
+
/** The maximum total number of records, arrays, and leaves one schema retains. */
|
|
70
|
+
var NODE_LIMIT = 16384;
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/core/errors.ts
|
|
73
|
+
/** An error raised by the form domain. */
|
|
74
|
+
var FormError = class extends Error {
|
|
75
|
+
/** The machine-readable reason for this failure. */
|
|
76
|
+
code;
|
|
77
|
+
/** Structured values that locate or explain this failure. */
|
|
78
|
+
context;
|
|
79
|
+
/**
|
|
80
|
+
* Create a form error.
|
|
81
|
+
*
|
|
82
|
+
* @param code - The machine-readable reason.
|
|
83
|
+
* @param message - The human-readable failure text.
|
|
84
|
+
* @param context - Optional structured failure details.
|
|
85
|
+
*/
|
|
86
|
+
constructor(code, message, context) {
|
|
87
|
+
super(message);
|
|
88
|
+
this.name = "FormError";
|
|
89
|
+
this.code = code;
|
|
90
|
+
if (context !== void 0) this.context = context;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Determine whether an unknown value is a form error.
|
|
95
|
+
*
|
|
96
|
+
* @param input - The value to inspect.
|
|
97
|
+
* @returns Whether the value is a {@link FormError} instance.
|
|
98
|
+
*/
|
|
99
|
+
function isFormError(input) {
|
|
100
|
+
return input instanceof FormError;
|
|
101
|
+
}
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/core/validators.ts
|
|
104
|
+
/**
|
|
105
|
+
* Determine whether an unknown value is a declared field control.
|
|
106
|
+
*
|
|
107
|
+
* @param input - The value to inspect.
|
|
108
|
+
* @returns Whether the value is a field control.
|
|
109
|
+
*/
|
|
110
|
+
function isFieldControl(input) {
|
|
111
|
+
return FIELD_CONTROLS.some((control) => control === input);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Determine whether an unknown value is a form lifecycle status.
|
|
115
|
+
*
|
|
116
|
+
* @param input - The value to inspect.
|
|
117
|
+
* @returns Whether the value is a form status.
|
|
118
|
+
*/
|
|
119
|
+
function isFormStatus(input) {
|
|
120
|
+
return FORM_STATUSES.some((status) => status === input);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Determine whether an unknown value has a form field value shape.
|
|
124
|
+
*
|
|
125
|
+
* @param input - The value to inspect.
|
|
126
|
+
* @returns Whether the value is a field value.
|
|
127
|
+
*/
|
|
128
|
+
function isFieldValue(input) {
|
|
129
|
+
return unionOf(isString, isFiniteNumber, isBoolean, arrayOf(isString))(input);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Determine whether an unknown value is one exact field choice record.
|
|
133
|
+
*
|
|
134
|
+
* @param input - The value to inspect.
|
|
135
|
+
* @returns Whether the value is a field choice.
|
|
136
|
+
*/
|
|
137
|
+
function isFieldChoice(input) {
|
|
138
|
+
const keys = attempt(() => isRecord(input) && Reflect.ownKeys(input).every((key) => isString(key)));
|
|
139
|
+
if (!keys.success || !keys.value) return false;
|
|
140
|
+
return recordOf({
|
|
141
|
+
value: isString,
|
|
142
|
+
label: isString,
|
|
143
|
+
help: isString,
|
|
144
|
+
disabled: isBoolean
|
|
145
|
+
}, ["help", "disabled"])(input);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Determine whether an unknown value is one exact field rule record.
|
|
149
|
+
*
|
|
150
|
+
* @param input - The value to inspect.
|
|
151
|
+
* @returns Whether the value is a structurally valid field rule.
|
|
152
|
+
*/
|
|
153
|
+
function isFieldRule(input) {
|
|
154
|
+
const keys = attempt(() => isRecord(input) && Reflect.ownKeys(input).every((key) => isString(key)));
|
|
155
|
+
if (!keys.success || !keys.value) return false;
|
|
156
|
+
return recordOf({
|
|
157
|
+
required: isBoolean,
|
|
158
|
+
minimum: unionOf(isFiniteNumber, isString),
|
|
159
|
+
maximum: unionOf(isFiniteNumber, isString),
|
|
160
|
+
step: isFiniteNumber,
|
|
161
|
+
pattern: isString,
|
|
162
|
+
email: isBoolean,
|
|
163
|
+
url: isBoolean,
|
|
164
|
+
integer: isBoolean,
|
|
165
|
+
alphanumeric: isBoolean,
|
|
166
|
+
custom: isFunction
|
|
167
|
+
}, true)(input);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Determine whether an unknown value is one exact discriminated form field.
|
|
171
|
+
*
|
|
172
|
+
* @remarks
|
|
173
|
+
* Metadata is admitted structurally as bounded JSON. An accessor-bearing metadata record is
|
|
174
|
+
* refused later when {@link cloneFormField} takes ownership, because ownership accepts enumerable
|
|
175
|
+
* data properties only.
|
|
176
|
+
*
|
|
177
|
+
* @param input - The value to inspect.
|
|
178
|
+
* @returns Whether the value is a structurally valid form field.
|
|
179
|
+
*/
|
|
180
|
+
function isFormField(input) {
|
|
181
|
+
const outcome = attempt(() => {
|
|
182
|
+
if (!isRecord(input) || !Object.hasOwn(input, "control") || !Object.hasOwn(input, "name")) return false;
|
|
183
|
+
const control = input.control;
|
|
184
|
+
if (!isFieldControl(control)) return false;
|
|
185
|
+
if (!Reflect.ownKeys(input).every((key) => {
|
|
186
|
+
if (!isString(key)) return false;
|
|
187
|
+
switch (control) {
|
|
188
|
+
case "text":
|
|
189
|
+
case "editor": return [
|
|
190
|
+
"control",
|
|
191
|
+
"name",
|
|
192
|
+
"label",
|
|
193
|
+
"help",
|
|
194
|
+
"group",
|
|
195
|
+
"hidden",
|
|
196
|
+
"disabled",
|
|
197
|
+
"locked",
|
|
198
|
+
"rule",
|
|
199
|
+
"meta",
|
|
200
|
+
"default",
|
|
201
|
+
"placeholder"
|
|
202
|
+
].includes(key);
|
|
203
|
+
case "password": return [
|
|
204
|
+
"control",
|
|
205
|
+
"name",
|
|
206
|
+
"label",
|
|
207
|
+
"help",
|
|
208
|
+
"group",
|
|
209
|
+
"hidden",
|
|
210
|
+
"disabled",
|
|
211
|
+
"locked",
|
|
212
|
+
"rule",
|
|
213
|
+
"meta",
|
|
214
|
+
"mask"
|
|
215
|
+
].includes(key);
|
|
216
|
+
case "number": return [
|
|
217
|
+
"control",
|
|
218
|
+
"name",
|
|
219
|
+
"label",
|
|
220
|
+
"help",
|
|
221
|
+
"group",
|
|
222
|
+
"hidden",
|
|
223
|
+
"disabled",
|
|
224
|
+
"locked",
|
|
225
|
+
"rule",
|
|
226
|
+
"meta",
|
|
227
|
+
"default",
|
|
228
|
+
"placeholder"
|
|
229
|
+
].includes(key);
|
|
230
|
+
case "date":
|
|
231
|
+
case "time":
|
|
232
|
+
case "datetime":
|
|
233
|
+
case "color":
|
|
234
|
+
case "confirm": return [
|
|
235
|
+
"control",
|
|
236
|
+
"name",
|
|
237
|
+
"label",
|
|
238
|
+
"help",
|
|
239
|
+
"group",
|
|
240
|
+
"hidden",
|
|
241
|
+
"disabled",
|
|
242
|
+
"locked",
|
|
243
|
+
"rule",
|
|
244
|
+
"meta",
|
|
245
|
+
"default"
|
|
246
|
+
].includes(key);
|
|
247
|
+
case "select": return [
|
|
248
|
+
"control",
|
|
249
|
+
"name",
|
|
250
|
+
"label",
|
|
251
|
+
"help",
|
|
252
|
+
"group",
|
|
253
|
+
"hidden",
|
|
254
|
+
"disabled",
|
|
255
|
+
"locked",
|
|
256
|
+
"rule",
|
|
257
|
+
"meta",
|
|
258
|
+
"choices",
|
|
259
|
+
"default",
|
|
260
|
+
"open"
|
|
261
|
+
].includes(key);
|
|
262
|
+
case "checkbox": return [
|
|
263
|
+
"control",
|
|
264
|
+
"name",
|
|
265
|
+
"label",
|
|
266
|
+
"help",
|
|
267
|
+
"group",
|
|
268
|
+
"hidden",
|
|
269
|
+
"disabled",
|
|
270
|
+
"locked",
|
|
271
|
+
"rule",
|
|
272
|
+
"meta",
|
|
273
|
+
"choices",
|
|
274
|
+
"default"
|
|
275
|
+
].includes(key);
|
|
276
|
+
case "file": return [
|
|
277
|
+
"control",
|
|
278
|
+
"name",
|
|
279
|
+
"label",
|
|
280
|
+
"help",
|
|
281
|
+
"group",
|
|
282
|
+
"hidden",
|
|
283
|
+
"disabled",
|
|
284
|
+
"locked",
|
|
285
|
+
"rule",
|
|
286
|
+
"meta",
|
|
287
|
+
"accept",
|
|
288
|
+
"multiple"
|
|
289
|
+
].includes(key);
|
|
290
|
+
}
|
|
291
|
+
})) return false;
|
|
292
|
+
const name = input.name;
|
|
293
|
+
const hasLabel = Object.hasOwn(input, "label");
|
|
294
|
+
const label = hasLabel ? input.label : void 0;
|
|
295
|
+
const hasHelp = Object.hasOwn(input, "help");
|
|
296
|
+
const help = hasHelp ? input.help : void 0;
|
|
297
|
+
const hasGroup = Object.hasOwn(input, "group");
|
|
298
|
+
const group = hasGroup ? input.group : void 0;
|
|
299
|
+
const hasHidden = Object.hasOwn(input, "hidden");
|
|
300
|
+
const hidden = hasHidden ? input.hidden : void 0;
|
|
301
|
+
const hasDisabled = Object.hasOwn(input, "disabled");
|
|
302
|
+
const disabled = hasDisabled ? input.disabled : void 0;
|
|
303
|
+
const hasLocked = Object.hasOwn(input, "locked");
|
|
304
|
+
const locked = hasLocked ? input.locked : void 0;
|
|
305
|
+
const hasRule = Object.hasOwn(input, "rule");
|
|
306
|
+
const rule = hasRule ? input.rule : void 0;
|
|
307
|
+
const hasMeta = Object.hasOwn(input, "meta");
|
|
308
|
+
const meta = hasMeta ? input.meta : void 0;
|
|
309
|
+
if (!isString(name) || hasLabel && !isString(label) || hasHelp && !isString(help) || hasGroup && !isString(group) || hasHidden && !isBoolean(hidden) || hasDisabled && !isBoolean(disabled) || hasLocked && !isBoolean(locked) || hasRule && !isFieldRule(rule) || hasMeta && !isBoundedJSONRecord(meta)) return false;
|
|
310
|
+
switch (control) {
|
|
311
|
+
case "text":
|
|
312
|
+
case "editor": {
|
|
313
|
+
const hasDefault = Object.hasOwn(input, "default");
|
|
314
|
+
const fallback = hasDefault ? input.default : void 0;
|
|
315
|
+
const hasPlaceholder = Object.hasOwn(input, "placeholder");
|
|
316
|
+
const placeholder = hasPlaceholder ? input.placeholder : void 0;
|
|
317
|
+
return (!hasDefault || isString(fallback)) && (!hasPlaceholder || isString(placeholder));
|
|
318
|
+
}
|
|
319
|
+
case "password": {
|
|
320
|
+
const hasMask = Object.hasOwn(input, "mask");
|
|
321
|
+
const mask = hasMask ? input.mask : void 0;
|
|
322
|
+
return !hasMask || isString(mask);
|
|
323
|
+
}
|
|
324
|
+
case "number": {
|
|
325
|
+
const hasDefault = Object.hasOwn(input, "default");
|
|
326
|
+
const fallback = hasDefault ? input.default : void 0;
|
|
327
|
+
const hasPlaceholder = Object.hasOwn(input, "placeholder");
|
|
328
|
+
const placeholder = hasPlaceholder ? input.placeholder : void 0;
|
|
329
|
+
return (!hasDefault || isFiniteNumber(fallback)) && (!hasPlaceholder || isString(placeholder));
|
|
330
|
+
}
|
|
331
|
+
case "date":
|
|
332
|
+
case "time":
|
|
333
|
+
case "datetime":
|
|
334
|
+
case "color": {
|
|
335
|
+
const hasDefault = Object.hasOwn(input, "default");
|
|
336
|
+
const fallback = hasDefault ? input.default : void 0;
|
|
337
|
+
return !hasDefault || isString(fallback);
|
|
338
|
+
}
|
|
339
|
+
case "confirm": {
|
|
340
|
+
const hasDefault = Object.hasOwn(input, "default");
|
|
341
|
+
const fallback = hasDefault ? input.default : void 0;
|
|
342
|
+
return !hasDefault || isBoolean(fallback);
|
|
343
|
+
}
|
|
344
|
+
case "select": {
|
|
345
|
+
if (!Object.hasOwn(input, "choices")) return false;
|
|
346
|
+
const choices = input.choices;
|
|
347
|
+
const hasDefault = Object.hasOwn(input, "default");
|
|
348
|
+
const fallback = hasDefault ? input.default : void 0;
|
|
349
|
+
const hasOpen = Object.hasOwn(input, "open");
|
|
350
|
+
const open = hasOpen ? input.open : void 0;
|
|
351
|
+
return arrayOf(isFieldChoice)(choices) && (!hasDefault || isString(fallback)) && (!hasOpen || isBoolean(open));
|
|
352
|
+
}
|
|
353
|
+
case "checkbox": {
|
|
354
|
+
if (!Object.hasOwn(input, "choices")) return false;
|
|
355
|
+
const choices = input.choices;
|
|
356
|
+
const hasDefault = Object.hasOwn(input, "default");
|
|
357
|
+
const fallback = hasDefault ? input.default : void 0;
|
|
358
|
+
return arrayOf(isFieldChoice)(choices) && (!hasDefault || arrayOf(isString)(fallback));
|
|
359
|
+
}
|
|
360
|
+
case "file": {
|
|
361
|
+
const hasAccept = Object.hasOwn(input, "accept");
|
|
362
|
+
const accept = hasAccept ? input.accept : void 0;
|
|
363
|
+
const hasMultiple = Object.hasOwn(input, "multiple");
|
|
364
|
+
const multiple = hasMultiple ? input.multiple : void 0;
|
|
365
|
+
return (!hasAccept || arrayOf(isString)(accept)) && (!hasMultiple || isBoolean(multiple));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
return outcome.success && outcome.value;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Determine whether an unknown value is one exact form group record.
|
|
373
|
+
*
|
|
374
|
+
* @param input - The value to inspect.
|
|
375
|
+
* @returns Whether the value is a form group.
|
|
376
|
+
*/
|
|
377
|
+
function isFormGroup(input) {
|
|
378
|
+
const keys = attempt(() => isRecord(input) && Reflect.ownKeys(input).every((key) => isString(key)));
|
|
379
|
+
if (!keys.success || !keys.value) return false;
|
|
380
|
+
return recordOf({
|
|
381
|
+
name: isString,
|
|
382
|
+
label: isString,
|
|
383
|
+
help: isString
|
|
384
|
+
}, ["help"])(input);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Determine whether an unknown value is one exact structural form schema.
|
|
388
|
+
*
|
|
389
|
+
* @param input - The value to inspect.
|
|
390
|
+
* @returns Whether the value is a structurally valid form schema.
|
|
391
|
+
*/
|
|
392
|
+
function isFormSchema(input) {
|
|
393
|
+
const keys = attempt(() => isRecord(input) && Reflect.ownKeys(input).every((key) => isString(key)));
|
|
394
|
+
if (!keys.success || !keys.value) return false;
|
|
395
|
+
return recordOf({
|
|
396
|
+
name: isString,
|
|
397
|
+
label: isString,
|
|
398
|
+
help: isString,
|
|
399
|
+
groups: arrayOf(isFormGroup),
|
|
400
|
+
fields: arrayOf(isFormField)
|
|
401
|
+
}, [
|
|
402
|
+
"name",
|
|
403
|
+
"label",
|
|
404
|
+
"help",
|
|
405
|
+
"groups"
|
|
406
|
+
])(input);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Determine whether an unknown value is a record of field values.
|
|
410
|
+
*
|
|
411
|
+
* @param input - The value to inspect.
|
|
412
|
+
* @returns Whether the value is a form values record.
|
|
413
|
+
*/
|
|
414
|
+
function isFormValues(input) {
|
|
415
|
+
const outcome = attempt(() => {
|
|
416
|
+
if (!isRecord(input)) return false;
|
|
417
|
+
return Reflect.ownKeys(input).every((key) => isString(key) && Object.hasOwn(input, key) && isFieldValue(input[key]));
|
|
418
|
+
});
|
|
419
|
+
return outcome.success && outcome.value;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Determine whether an unknown value is one exact field error record.
|
|
423
|
+
*
|
|
424
|
+
* @param input - The value to inspect.
|
|
425
|
+
* @returns Whether the value is a field error.
|
|
426
|
+
*/
|
|
427
|
+
function isFieldError(input) {
|
|
428
|
+
const keys = attempt(() => isRecord(input) && Reflect.ownKeys(input).every((key) => isString(key)));
|
|
429
|
+
if (!keys.success || !keys.value) return false;
|
|
430
|
+
return recordOf({
|
|
431
|
+
field: isString,
|
|
432
|
+
message: isString,
|
|
433
|
+
rule: literalOf("required", "minimum", "maximum", "step", "pattern", "email", "url", "integer", "alphanumeric")
|
|
434
|
+
}, ["rule"])(input);
|
|
435
|
+
}
|
|
436
|
+
//#endregion
|
|
437
|
+
//#region src/core/cloners.ts
|
|
438
|
+
function cloneValue(value) {
|
|
439
|
+
return isArray(value) ? Object.freeze(value.slice()) : value;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Clone a field's choices into an owned frozen snapshot.
|
|
443
|
+
*
|
|
444
|
+
* @param choices - The choices to own.
|
|
445
|
+
* @returns A frozen list of frozen choice records.
|
|
446
|
+
*/
|
|
447
|
+
function cloneChoices(choices) {
|
|
448
|
+
return Object.freeze(choices.map((choice) => Object.freeze({ ...choice })));
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Clone one form field into an owned frozen snapshot.
|
|
452
|
+
*
|
|
453
|
+
* @param field - The field to own.
|
|
454
|
+
* @returns A frozen field with every nested collection owned.
|
|
455
|
+
* @throws A {@link FormError} coded `SCHEMA` when accessor-bearing metadata cannot be owned.
|
|
456
|
+
*/
|
|
457
|
+
function cloneFormField(field) {
|
|
458
|
+
const rule = field.rule === void 0 ? {} : { rule: Object.freeze({ ...field.rule }) };
|
|
459
|
+
let meta = {};
|
|
460
|
+
if (field.meta !== void 0) try {
|
|
461
|
+
meta = { meta: cloneJSONRecord(field.meta) };
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (!isContractError(error)) throw error;
|
|
464
|
+
throw new FormError("SCHEMA", `Field "${field.name}" has metadata that cannot be owned`, { field: field.name });
|
|
465
|
+
}
|
|
466
|
+
switch (field.control) {
|
|
467
|
+
case "select": return Object.freeze({
|
|
468
|
+
...field,
|
|
469
|
+
...rule,
|
|
470
|
+
...meta,
|
|
471
|
+
choices: cloneChoices(field.choices)
|
|
472
|
+
});
|
|
473
|
+
case "checkbox": return Object.freeze({
|
|
474
|
+
...field,
|
|
475
|
+
...rule,
|
|
476
|
+
...meta,
|
|
477
|
+
choices: cloneChoices(field.choices),
|
|
478
|
+
...field.default === void 0 ? {} : { default: cloneValue(field.default) }
|
|
479
|
+
});
|
|
480
|
+
case "file": return Object.freeze({
|
|
481
|
+
...field,
|
|
482
|
+
...rule,
|
|
483
|
+
...meta,
|
|
484
|
+
...field.accept === void 0 ? {} : { accept: cloneValue(field.accept) }
|
|
485
|
+
});
|
|
486
|
+
case "text":
|
|
487
|
+
case "editor":
|
|
488
|
+
case "password":
|
|
489
|
+
case "number":
|
|
490
|
+
case "date":
|
|
491
|
+
case "time":
|
|
492
|
+
case "datetime":
|
|
493
|
+
case "color":
|
|
494
|
+
case "confirm": return Object.freeze({
|
|
495
|
+
...field,
|
|
496
|
+
...rule,
|
|
497
|
+
...meta
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Clone a form schema into an owned frozen snapshot.
|
|
503
|
+
*
|
|
504
|
+
* @param schema - The schema to own.
|
|
505
|
+
* @returns A frozen schema with every nested record and list owned.
|
|
506
|
+
*/
|
|
507
|
+
function cloneFormSchema(schema) {
|
|
508
|
+
const groups = schema.groups;
|
|
509
|
+
return Object.freeze({
|
|
510
|
+
...schema,
|
|
511
|
+
...groups === void 0 ? {} : { groups: Object.freeze(groups.map((group) => Object.freeze({ ...group }))) },
|
|
512
|
+
fields: Object.freeze(schema.fields.map((field) => cloneFormField(field)))
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
//#endregion
|
|
516
|
+
//#region src/core/helpers.ts
|
|
517
|
+
/**
|
|
518
|
+
* Check whether a value has the shape required by one field control.
|
|
519
|
+
*
|
|
520
|
+
* @param field - The field that owns the value.
|
|
521
|
+
* @param value - The unknown value to inspect.
|
|
522
|
+
* @returns Whether the control can hold the value.
|
|
523
|
+
*/
|
|
524
|
+
function matchesField(field, value) {
|
|
525
|
+
if (isString(value) && value.length > 65536) return false;
|
|
526
|
+
let entries;
|
|
527
|
+
if (isArray(value)) {
|
|
528
|
+
const length = attempt(() => value.length);
|
|
529
|
+
if (!length.success || length.value > 1024) return false;
|
|
530
|
+
const read = readArrayEntries(value);
|
|
531
|
+
if (!read.success || !read.value.dense) return false;
|
|
532
|
+
entries = read.value.entries;
|
|
533
|
+
if (entries.some((entry) => !isString(entry) || entry.length > 65536)) return false;
|
|
534
|
+
}
|
|
535
|
+
switch (field.control) {
|
|
536
|
+
case "text":
|
|
537
|
+
case "editor":
|
|
538
|
+
case "password": return isString(value);
|
|
539
|
+
case "number": return isFiniteNumber(value);
|
|
540
|
+
case "date": return isString(value) && DATE_PATTERN.test(value);
|
|
541
|
+
case "time": return isString(value) && TIME_PATTERN.test(value);
|
|
542
|
+
case "datetime": return isString(value) && DATETIME_PATTERN.test(value);
|
|
543
|
+
case "color": return isString(value) && COLOR_PATTERN.test(value);
|
|
544
|
+
case "confirm": return isBoolean(value);
|
|
545
|
+
case "select": return isString(value) && !field.choices.some((choice) => choice.value === value && choice.disabled === true) && (field.open === true || field.choices.some((choice) => choice.value === value && choice.disabled !== true));
|
|
546
|
+
case "checkbox": return entries !== void 0 && entries.every((entry) => isString(entry) && field.choices.some((choice) => choice.value === entry && choice.disabled !== true)) && new Set(entries).size === entries.length;
|
|
547
|
+
case "file": return entries !== void 0 && (field.multiple === true || entries.length <= 1);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Decide whether a raw binding value projects to an answered field.
|
|
552
|
+
*
|
|
553
|
+
* @remarks
|
|
554
|
+
* Bind with `fill(name, matchesAnswer(raw) ? raw : undefined)`. This projection treats an absent
|
|
555
|
+
* value and a string containing only whitespace as unanswered. Every other field value is an
|
|
556
|
+
* answer, including an empty list, `false`, and zero. Core evaluation does not use this projection:
|
|
557
|
+
* its `required` rule remains presence-only.
|
|
558
|
+
*
|
|
559
|
+
* @param value - The raw field value, or absence.
|
|
560
|
+
* @returns Whether the binding should preserve the value as an answer.
|
|
561
|
+
*/
|
|
562
|
+
function matchesAnswer(value) {
|
|
563
|
+
return value !== void 0 && (!isString(value) || value.trim().length > 0);
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Check whether a named rule applies to one field control.
|
|
567
|
+
*
|
|
568
|
+
* @remarks
|
|
569
|
+
* The runtime control-membership check keeps this boundary total for JavaScript callers that
|
|
570
|
+
* bypass the declared {@link FieldControl} contract.
|
|
571
|
+
*
|
|
572
|
+
* @param control - The field control to inspect.
|
|
573
|
+
* @param rule - The named rule to inspect.
|
|
574
|
+
* @returns Whether the control evaluates that rule.
|
|
575
|
+
*/
|
|
576
|
+
function appliesRule(control, rule) {
|
|
577
|
+
if (!FIELD_CONTROLS.some((candidate) => candidate === control)) return false;
|
|
578
|
+
switch (rule) {
|
|
579
|
+
case "required": return true;
|
|
580
|
+
case "minimum":
|
|
581
|
+
case "maximum": return control !== "color" && control !== "confirm" && control !== "select";
|
|
582
|
+
case "step": return control === "number";
|
|
583
|
+
case "pattern":
|
|
584
|
+
case "email":
|
|
585
|
+
case "url":
|
|
586
|
+
case "alphanumeric": return control !== "number" && control !== "confirm" && control !== "checkbox" && control !== "file";
|
|
587
|
+
case "integer": return control !== "confirm" && control !== "checkbox" && control !== "file";
|
|
588
|
+
}
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Evaluate one field rule against its current value.
|
|
593
|
+
*
|
|
594
|
+
* @param field - The field and rule to evaluate.
|
|
595
|
+
* @param value - The current value, or absence.
|
|
596
|
+
* @param values - Every value available to a custom rule.
|
|
597
|
+
* @param messages - Optional rule-specific message replacements.
|
|
598
|
+
* @returns Every failure in rule order.
|
|
599
|
+
*/
|
|
600
|
+
function evaluateField(field, value, values, messages) {
|
|
601
|
+
const errors = [];
|
|
602
|
+
const rule = field.rule;
|
|
603
|
+
if (value === void 0) {
|
|
604
|
+
if (rule?.required === true && appliesRule(field.control, "required")) errors.push(Object.freeze({
|
|
605
|
+
field: field.name,
|
|
606
|
+
message: formatMessage("required", void 0, messages),
|
|
607
|
+
rule: "required"
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
if (rule === void 0) return Object.freeze(errors);
|
|
611
|
+
if (rule.minimum !== void 0 && appliesRule(field.control, "minimum")) {
|
|
612
|
+
let failed = false;
|
|
613
|
+
switch (field.control) {
|
|
614
|
+
case "text":
|
|
615
|
+
case "editor":
|
|
616
|
+
case "password":
|
|
617
|
+
failed = isString(value) && isFiniteNumber(rule.minimum) && value.length < rule.minimum;
|
|
618
|
+
break;
|
|
619
|
+
case "number":
|
|
620
|
+
failed = isFiniteNumber(value) && isFiniteNumber(rule.minimum) && value < rule.minimum;
|
|
621
|
+
break;
|
|
622
|
+
case "date":
|
|
623
|
+
case "time":
|
|
624
|
+
case "datetime":
|
|
625
|
+
failed = isString(value) && isString(rule.minimum) && value < rule.minimum;
|
|
626
|
+
break;
|
|
627
|
+
case "checkbox":
|
|
628
|
+
case "file": failed = isArray(value) && isFiniteNumber(rule.minimum) && value.length < rule.minimum;
|
|
629
|
+
}
|
|
630
|
+
if (failed) errors.push(Object.freeze({
|
|
631
|
+
field: field.name,
|
|
632
|
+
message: formatMessage("minimum", rule.minimum, messages),
|
|
633
|
+
rule: "minimum"
|
|
634
|
+
}));
|
|
635
|
+
}
|
|
636
|
+
if (rule.maximum !== void 0 && appliesRule(field.control, "maximum")) {
|
|
637
|
+
let failed = false;
|
|
638
|
+
switch (field.control) {
|
|
639
|
+
case "text":
|
|
640
|
+
case "editor":
|
|
641
|
+
case "password":
|
|
642
|
+
failed = isString(value) && isFiniteNumber(rule.maximum) && value.length > rule.maximum;
|
|
643
|
+
break;
|
|
644
|
+
case "number":
|
|
645
|
+
failed = isFiniteNumber(value) && isFiniteNumber(rule.maximum) && value > rule.maximum;
|
|
646
|
+
break;
|
|
647
|
+
case "date":
|
|
648
|
+
case "time":
|
|
649
|
+
case "datetime":
|
|
650
|
+
failed = isString(value) && isString(rule.maximum) && value > rule.maximum;
|
|
651
|
+
break;
|
|
652
|
+
case "checkbox":
|
|
653
|
+
case "file": failed = isArray(value) && isFiniteNumber(rule.maximum) && value.length > rule.maximum;
|
|
654
|
+
}
|
|
655
|
+
if (failed) errors.push(Object.freeze({
|
|
656
|
+
field: field.name,
|
|
657
|
+
message: formatMessage("maximum", rule.maximum, messages),
|
|
658
|
+
rule: "maximum"
|
|
659
|
+
}));
|
|
660
|
+
}
|
|
661
|
+
if (rule.step !== void 0 && appliesRule(field.control, "step") && isFiniteNumber(value)) {
|
|
662
|
+
const multiple = (value - (isFiniteNumber(rule.minimum) ? rule.minimum : 0)) / rule.step;
|
|
663
|
+
if (!isFiniteNumber(rule.step) || rule.step === 0 || !isFiniteNumber(multiple) || Math.abs(multiple - Math.round(multiple)) > 1e-9) errors.push(Object.freeze({
|
|
664
|
+
field: field.name,
|
|
665
|
+
message: formatMessage("step", rule.step, messages),
|
|
666
|
+
rule: "step"
|
|
667
|
+
}));
|
|
668
|
+
}
|
|
669
|
+
if (isString(value)) {
|
|
670
|
+
if (rule.pattern !== void 0 && appliesRule(field.control, "pattern")) {
|
|
671
|
+
const pattern = rule.pattern;
|
|
672
|
+
if (pattern.length > 256) errors.push(Object.freeze({
|
|
673
|
+
field: field.name,
|
|
674
|
+
message: formatMessage("pattern", void 0, messages),
|
|
675
|
+
rule: "pattern"
|
|
676
|
+
}));
|
|
677
|
+
else {
|
|
678
|
+
const outcome = attempt(() => new RegExp(pattern).test(value));
|
|
679
|
+
if (!outcome.success || !outcome.value) errors.push(Object.freeze({
|
|
680
|
+
field: field.name,
|
|
681
|
+
message: formatMessage("pattern", void 0, messages),
|
|
682
|
+
rule: "pattern"
|
|
683
|
+
}));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (rule.email === true && appliesRule(field.control, "email") && !EMAIL_PATTERN.test(value)) errors.push(Object.freeze({
|
|
687
|
+
field: field.name,
|
|
688
|
+
message: formatMessage("email", void 0, messages),
|
|
689
|
+
rule: "email"
|
|
690
|
+
}));
|
|
691
|
+
if (rule.url === true && appliesRule(field.control, "url") && !URL_PATTERN.test(value)) errors.push(Object.freeze({
|
|
692
|
+
field: field.name,
|
|
693
|
+
message: formatMessage("url", void 0, messages),
|
|
694
|
+
rule: "url"
|
|
695
|
+
}));
|
|
696
|
+
if (rule.alphanumeric === true && appliesRule(field.control, "alphanumeric") && !ALPHANUMERIC_PATTERN.test(value)) errors.push(Object.freeze({
|
|
697
|
+
field: field.name,
|
|
698
|
+
message: formatMessage("alphanumeric", void 0, messages),
|
|
699
|
+
rule: "alphanumeric"
|
|
700
|
+
}));
|
|
701
|
+
if (rule.integer === true && appliesRule(field.control, "integer") && !INTEGER_PATTERN.test(value)) errors.push(Object.freeze({
|
|
702
|
+
field: field.name,
|
|
703
|
+
message: formatMessage("integer", void 0, messages),
|
|
704
|
+
rule: "integer"
|
|
705
|
+
}));
|
|
706
|
+
}
|
|
707
|
+
if (value !== void 0 && field.control === "number" && rule.integer === true && appliesRule(field.control, "integer") && !isInteger(value)) errors.push(Object.freeze({
|
|
708
|
+
field: field.name,
|
|
709
|
+
message: formatMessage("integer", void 0, messages),
|
|
710
|
+
rule: "integer"
|
|
711
|
+
}));
|
|
712
|
+
if (rule.custom !== void 0) {
|
|
713
|
+
const result = rule.custom(value, values);
|
|
714
|
+
if (isString(result)) errors.push(Object.freeze({
|
|
715
|
+
field: field.name,
|
|
716
|
+
message: result
|
|
717
|
+
}));
|
|
718
|
+
}
|
|
719
|
+
return Object.freeze(errors);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Evaluate every active field in schema order.
|
|
723
|
+
*
|
|
724
|
+
* @param schema - The form schema to evaluate.
|
|
725
|
+
* @param values - The values keyed by field name.
|
|
726
|
+
* @param options - Optional message replacements and the effective disabled field set.
|
|
727
|
+
* @returns Every field failure in schema and rule order.
|
|
728
|
+
*/
|
|
729
|
+
function evaluateForm(schema, values, options) {
|
|
730
|
+
const errors = [];
|
|
731
|
+
for (const field of schema.fields) if (options?.disabled === void 0 ? field.disabled !== true : !options.disabled.has(field.name)) {
|
|
732
|
+
const value = Object.hasOwn(values, field.name) ? values[field.name] : void 0;
|
|
733
|
+
errors.push(...evaluateField(field, value, values, options?.messages));
|
|
734
|
+
}
|
|
735
|
+
return Object.freeze(errors);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Compute the values explicitly seeded by a schema.
|
|
739
|
+
*
|
|
740
|
+
* @param schema - The schema whose defaults to collect.
|
|
741
|
+
* @returns A value record containing only fields with defaults.
|
|
742
|
+
*/
|
|
743
|
+
function computeDefaults(schema) {
|
|
744
|
+
const defaults = {};
|
|
745
|
+
for (const field of schema.fields) switch (field.control) {
|
|
746
|
+
case "checkbox":
|
|
747
|
+
if (field.default !== void 0) Object.defineProperty(defaults, field.name, {
|
|
748
|
+
value: cloneValue(field.default),
|
|
749
|
+
enumerable: true,
|
|
750
|
+
configurable: true,
|
|
751
|
+
writable: true
|
|
752
|
+
});
|
|
753
|
+
break;
|
|
754
|
+
case "password":
|
|
755
|
+
case "file": break;
|
|
756
|
+
case "text":
|
|
757
|
+
case "editor":
|
|
758
|
+
case "number":
|
|
759
|
+
case "date":
|
|
760
|
+
case "time":
|
|
761
|
+
case "datetime":
|
|
762
|
+
case "color":
|
|
763
|
+
case "confirm":
|
|
764
|
+
case "select": if (field.default !== void 0) Object.defineProperty(defaults, field.name, {
|
|
765
|
+
value: field.default,
|
|
766
|
+
enumerable: true,
|
|
767
|
+
configurable: true,
|
|
768
|
+
writable: true
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
return Object.freeze(defaults);
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Compare two field values by scalar identity or ordered list content.
|
|
775
|
+
*
|
|
776
|
+
* @param a - The first field value.
|
|
777
|
+
* @param b - The second field value.
|
|
778
|
+
* @returns Whether both values contain the same answer.
|
|
779
|
+
*/
|
|
780
|
+
function matchesValue(a, b) {
|
|
781
|
+
if (isArray(a) || isArray(b)) return isArray(a) && isArray(b) && a.length === b.length && a.every((entry, index) => entry === b[index]);
|
|
782
|
+
return a === b;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Extract the names whose answers differ between two form value records.
|
|
786
|
+
*
|
|
787
|
+
* @remarks
|
|
788
|
+
* Presence is compared in both directions before present values are compared through
|
|
789
|
+
* {@link matchesValue}. The returned set is a new snapshot, exposed as readonly because later
|
|
790
|
+
* changes to either input never alter its membership.
|
|
791
|
+
*
|
|
792
|
+
* @param current - The values held now.
|
|
793
|
+
* @param opened - The values held when the form opened.
|
|
794
|
+
* @returns A readonly snapshot of changed field names.
|
|
795
|
+
*/
|
|
796
|
+
function extractChanges(current, opened) {
|
|
797
|
+
const names = /* @__PURE__ */ new Set([...Object.keys(current), ...Object.keys(opened)]);
|
|
798
|
+
const changed = /* @__PURE__ */ new Set();
|
|
799
|
+
for (const name of names) {
|
|
800
|
+
if (Object.hasOwn(current, name) !== Object.hasOwn(opened, name)) {
|
|
801
|
+
changed.add(name);
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
const now = current[name];
|
|
805
|
+
const before = opened[name];
|
|
806
|
+
if (now === void 0 || before === void 0 || !matchesValue(now, before)) changed.add(name);
|
|
807
|
+
}
|
|
808
|
+
return changed;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Compare two form value records by keys and value content.
|
|
812
|
+
*
|
|
813
|
+
* @param a - The first value record.
|
|
814
|
+
* @param b - The second value record.
|
|
815
|
+
* @returns Whether both records contain the same answers.
|
|
816
|
+
*/
|
|
817
|
+
function matchesValues(a, b) {
|
|
818
|
+
return extractChanges(a, b).size === 0;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Resolve and interpolate one rule message.
|
|
822
|
+
*
|
|
823
|
+
* @param rule - The rule whose message to resolve.
|
|
824
|
+
* @param limit - The optional operand substituted for `{limit}`.
|
|
825
|
+
* @param messages - Optional rule-specific message replacements.
|
|
826
|
+
* @returns The resolved failure text.
|
|
827
|
+
*/
|
|
828
|
+
function formatMessage(rule, limit, messages) {
|
|
829
|
+
const message = messages?.[rule] ?? RULE_MESSAGES[rule];
|
|
830
|
+
return limit === void 0 ? message : message.replaceAll("{limit}", String(limit));
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Project a schema into JSON while removing custom validators and absent values.
|
|
834
|
+
*
|
|
835
|
+
* @param schema - The schema to project.
|
|
836
|
+
* @returns A deep JSON copy of the serializable schema.
|
|
837
|
+
* @throws A {@link FormError} coded `SCHEMA` when accessor-bearing metadata cannot be owned. A
|
|
838
|
+
* non-contract throw while reading metadata escapes unchanged.
|
|
839
|
+
*/
|
|
840
|
+
function serializeForm(schema) {
|
|
841
|
+
const output = {};
|
|
842
|
+
if (schema.name !== void 0) output.name = schema.name;
|
|
843
|
+
if (schema.label !== void 0) output.label = schema.label;
|
|
844
|
+
if (schema.help !== void 0) output.help = schema.help;
|
|
845
|
+
if (schema.groups !== void 0) output.groups = schema.groups.map((group) => {
|
|
846
|
+
const entry = {
|
|
847
|
+
name: group.name,
|
|
848
|
+
label: group.label
|
|
849
|
+
};
|
|
850
|
+
if (group.help !== void 0) entry.help = group.help;
|
|
851
|
+
return entry;
|
|
852
|
+
});
|
|
853
|
+
output.fields = schema.fields.map((field) => {
|
|
854
|
+
const entry = {
|
|
855
|
+
control: field.control,
|
|
856
|
+
name: field.name
|
|
857
|
+
};
|
|
858
|
+
if (field.label !== void 0) entry.label = field.label;
|
|
859
|
+
if (field.help !== void 0) entry.help = field.help;
|
|
860
|
+
if (field.group !== void 0) entry.group = field.group;
|
|
861
|
+
if (field.hidden !== void 0) entry.hidden = field.hidden;
|
|
862
|
+
if (field.disabled !== void 0) entry.disabled = field.disabled;
|
|
863
|
+
if (field.locked !== void 0) entry.locked = field.locked;
|
|
864
|
+
const meta = field.meta;
|
|
865
|
+
if (meta !== void 0) try {
|
|
866
|
+
entry.meta = cloneJSONRecord(meta);
|
|
867
|
+
} catch (error) {
|
|
868
|
+
if (!isContractError(error)) throw error;
|
|
869
|
+
throw new FormError("SCHEMA", `Field "${field.name}" has metadata that cannot be owned`, { field: field.name });
|
|
870
|
+
}
|
|
871
|
+
switch (field.control) {
|
|
872
|
+
case "text":
|
|
873
|
+
case "editor":
|
|
874
|
+
case "number":
|
|
875
|
+
if (field.default !== void 0) entry.default = field.default;
|
|
876
|
+
if (field.placeholder !== void 0) entry.placeholder = field.placeholder;
|
|
877
|
+
break;
|
|
878
|
+
case "password":
|
|
879
|
+
if (field.mask !== void 0) entry.mask = field.mask;
|
|
880
|
+
break;
|
|
881
|
+
case "date":
|
|
882
|
+
case "time":
|
|
883
|
+
case "datetime":
|
|
884
|
+
case "color":
|
|
885
|
+
case "confirm":
|
|
886
|
+
if (field.default !== void 0) entry.default = field.default;
|
|
887
|
+
break;
|
|
888
|
+
case "select":
|
|
889
|
+
entry.choices = field.choices.map((choice) => {
|
|
890
|
+
const option = {
|
|
891
|
+
value: choice.value,
|
|
892
|
+
label: choice.label
|
|
893
|
+
};
|
|
894
|
+
if (choice.help !== void 0) option.help = choice.help;
|
|
895
|
+
if (choice.disabled !== void 0) option.disabled = choice.disabled;
|
|
896
|
+
return option;
|
|
897
|
+
});
|
|
898
|
+
if (field.default !== void 0) entry.default = field.default;
|
|
899
|
+
if (field.open !== void 0) entry.open = field.open;
|
|
900
|
+
break;
|
|
901
|
+
case "checkbox":
|
|
902
|
+
entry.choices = field.choices.map((choice) => {
|
|
903
|
+
const option = {
|
|
904
|
+
value: choice.value,
|
|
905
|
+
label: choice.label
|
|
906
|
+
};
|
|
907
|
+
if (choice.help !== void 0) option.help = choice.help;
|
|
908
|
+
if (choice.disabled !== void 0) option.disabled = choice.disabled;
|
|
909
|
+
return option;
|
|
910
|
+
});
|
|
911
|
+
if (field.default !== void 0) entry.default = field.default;
|
|
912
|
+
break;
|
|
913
|
+
case "file":
|
|
914
|
+
if (field.accept !== void 0) entry.accept = field.accept;
|
|
915
|
+
if (field.multiple !== void 0) entry.multiple = field.multiple;
|
|
916
|
+
}
|
|
917
|
+
if (field.rule !== void 0) {
|
|
918
|
+
const rule = {};
|
|
919
|
+
if (field.rule.required !== void 0) rule.required = field.rule.required;
|
|
920
|
+
if (field.rule.minimum !== void 0) rule.minimum = field.rule.minimum;
|
|
921
|
+
if (field.rule.maximum !== void 0) rule.maximum = field.rule.maximum;
|
|
922
|
+
if (field.rule.step !== void 0) rule.step = field.rule.step;
|
|
923
|
+
if (field.rule.pattern !== void 0) rule.pattern = field.rule.pattern;
|
|
924
|
+
if (field.rule.email !== void 0) rule.email = field.rule.email;
|
|
925
|
+
if (field.rule.url !== void 0) rule.url = field.rule.url;
|
|
926
|
+
if (field.rule.integer !== void 0) rule.integer = field.rule.integer;
|
|
927
|
+
if (field.rule.alphanumeric !== void 0) rule.alphanumeric = field.rule.alphanumeric;
|
|
928
|
+
if (Object.keys(rule).length > 0) entry.rule = rule;
|
|
929
|
+
}
|
|
930
|
+
return entry;
|
|
931
|
+
});
|
|
932
|
+
return cloneJSONRecord(output);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Select referenced groups in first-reference field order.
|
|
936
|
+
*
|
|
937
|
+
* @param schema - The schema whose group references to resolve.
|
|
938
|
+
* @returns The referenced schema groups without duplicates.
|
|
939
|
+
*/
|
|
940
|
+
function extractGroups(schema) {
|
|
941
|
+
const groups = [];
|
|
942
|
+
if (schema.groups === void 0) return Object.freeze(groups);
|
|
943
|
+
for (const field of schema.fields) if (field.group !== void 0 && !groups.some((group) => group.name === field.group)) {
|
|
944
|
+
const group = schema.groups.find((entry) => entry.name === field.group);
|
|
945
|
+
if (group !== void 0) groups.push(group);
|
|
946
|
+
}
|
|
947
|
+
return Object.freeze(groups);
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Audit a structurally valid schema for domain invariants.
|
|
951
|
+
*
|
|
952
|
+
* @param schema - The form schema to audit.
|
|
953
|
+
* @returns Human-readable invariant violations, or an empty list when the schema is sound.
|
|
954
|
+
*/
|
|
955
|
+
function auditSchema(schema) {
|
|
956
|
+
const faults = [];
|
|
957
|
+
const fields = /* @__PURE__ */ new Set();
|
|
958
|
+
const groups = /* @__PURE__ */ new Set();
|
|
959
|
+
let choiceExceeded;
|
|
960
|
+
let nameExceeded = schema.name !== void 0 && schema.name.length > 128;
|
|
961
|
+
if (schema.fields.length > 512) faults.push(`Schema declares more than 512 fields`);
|
|
962
|
+
if (schema.groups !== void 0 && schema.groups.length > 64) faults.push(`Schema declares more than 64 groups`);
|
|
963
|
+
if (schema.groups !== void 0) {
|
|
964
|
+
const count = Math.min(schema.groups.length, 65);
|
|
965
|
+
for (let index = 0; index < count; index += 1) {
|
|
966
|
+
const group = schema.groups[index];
|
|
967
|
+
if (group !== void 0 && group.name.length > 128) nameExceeded = true;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const fieldCount = Math.min(schema.fields.length, 513);
|
|
971
|
+
for (let index = 0; index < fieldCount; index += 1) {
|
|
972
|
+
const field = schema.fields[index];
|
|
973
|
+
if (field === void 0) continue;
|
|
974
|
+
if (field.name.length > 128 || field.group !== void 0 && field.group.length > 128) nameExceeded = true;
|
|
975
|
+
if (choiceExceeded === void 0 && (field.control === "select" || field.control === "checkbox") && field.choices.length > 1024) choiceExceeded = field.name;
|
|
976
|
+
}
|
|
977
|
+
if (choiceExceeded !== void 0) faults.push(`Field "${choiceExceeded}" offers more than ${CHOICE_LIMIT} choices`);
|
|
978
|
+
if (nameExceeded) faults.push(`Schema contains a name longer than 128`);
|
|
979
|
+
const pending = [schema];
|
|
980
|
+
const metadata = [false];
|
|
981
|
+
let position = 0;
|
|
982
|
+
let stringExceeded = false;
|
|
983
|
+
let textExceeded = false;
|
|
984
|
+
let nodeExceeded = false;
|
|
985
|
+
let text = 0;
|
|
986
|
+
while (position < pending.length) {
|
|
987
|
+
const node = pending[position];
|
|
988
|
+
const inMeta = metadata[position] === true;
|
|
989
|
+
position += 1;
|
|
990
|
+
if (isString(node)) {
|
|
991
|
+
if (node.length > 65536) stringExceeded = true;
|
|
992
|
+
text = Math.min(TEXT_LIMIT + 1, text + node.length);
|
|
993
|
+
if (text > 1048576) textExceeded = true;
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
if (isArray(node)) {
|
|
997
|
+
const length = attempt(() => node.length);
|
|
998
|
+
if (!length.success || length.value > 16384 - pending.length) {
|
|
999
|
+
nodeExceeded = true;
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
const read = readArrayEntries(node);
|
|
1003
|
+
if (!read.success || !read.value.dense) continue;
|
|
1004
|
+
for (let index = 0; index < read.value.entries.length; index += 1) {
|
|
1005
|
+
const entry = read.value.entries[index];
|
|
1006
|
+
if (entry !== void 0) {
|
|
1007
|
+
pending.push(entry);
|
|
1008
|
+
metadata.push(inMeta);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
if (!isRecord(node)) continue;
|
|
1014
|
+
const keys = attempt(() => Object.keys(node));
|
|
1015
|
+
if (!keys.success) continue;
|
|
1016
|
+
for (const key of keys.value) {
|
|
1017
|
+
if (inMeta) {
|
|
1018
|
+
if (key.length > 65536) stringExceeded = true;
|
|
1019
|
+
text = Math.min(TEXT_LIMIT + 1, text + key.length);
|
|
1020
|
+
if (text > 1048576) textExceeded = true;
|
|
1021
|
+
}
|
|
1022
|
+
const value = attempt(() => node[key]);
|
|
1023
|
+
if (!value.success || value.value === void 0) continue;
|
|
1024
|
+
if (pending.length >= 16384) {
|
|
1025
|
+
nodeExceeded = true;
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
pending.push(value.value);
|
|
1029
|
+
metadata.push(inMeta || key === "meta");
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (stringExceeded) faults.push(`Schema contains a string longer than ${STRING_LIMIT}`);
|
|
1033
|
+
if (textExceeded) faults.push(`Schema retains more than ${TEXT_LIMIT} string code units`);
|
|
1034
|
+
if (nodeExceeded) faults.push(`Schema retains more than ${NODE_LIMIT} nodes`);
|
|
1035
|
+
if (schema.groups !== void 0) {
|
|
1036
|
+
const count = Math.min(schema.groups.length, 65);
|
|
1037
|
+
for (let index = 0; index < count; index += 1) {
|
|
1038
|
+
const group = schema.groups[index];
|
|
1039
|
+
if (group === void 0) continue;
|
|
1040
|
+
if (groups.has(group.name)) faults.push(`Group "${group.name}" is declared more than once`);
|
|
1041
|
+
groups.add(group.name);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
for (let fieldIndex = 0; fieldIndex < fieldCount; fieldIndex += 1) {
|
|
1045
|
+
const field = schema.fields[fieldIndex];
|
|
1046
|
+
if (field === void 0) continue;
|
|
1047
|
+
if (field.name.length === 0) faults.push("Field \"\" has an empty name");
|
|
1048
|
+
if (field.name === "__proto__") faults.push("Field \"__proto__\" has a refused name");
|
|
1049
|
+
if (fields.has(field.name)) faults.push(`Field "${field.name}" is declared more than once`);
|
|
1050
|
+
fields.add(field.name);
|
|
1051
|
+
if (field.group !== void 0 && !groups.has(field.group)) faults.push(`Field "${field.name}" references missing group "${field.group}"`);
|
|
1052
|
+
switch (field.control) {
|
|
1053
|
+
case "password":
|
|
1054
|
+
case "file": break;
|
|
1055
|
+
case "text":
|
|
1056
|
+
case "editor":
|
|
1057
|
+
case "number":
|
|
1058
|
+
case "date":
|
|
1059
|
+
case "time":
|
|
1060
|
+
case "datetime":
|
|
1061
|
+
case "color":
|
|
1062
|
+
case "confirm":
|
|
1063
|
+
case "select":
|
|
1064
|
+
case "checkbox": if (field.default !== void 0 && !matchesField(field, field.default)) faults.push(`Field "${field.name}" has an invalid default`);
|
|
1065
|
+
}
|
|
1066
|
+
if (field.control === "select" || field.control === "checkbox") {
|
|
1067
|
+
const choices = /* @__PURE__ */ new Set();
|
|
1068
|
+
const count = Math.min(field.choices.length, CHOICE_LIMIT + 1);
|
|
1069
|
+
let enabled = 0;
|
|
1070
|
+
for (let index = 0; index < count; index += 1) {
|
|
1071
|
+
const choice = field.choices[index];
|
|
1072
|
+
if (choice === void 0) continue;
|
|
1073
|
+
if (choice.disabled !== true) enabled += 1;
|
|
1074
|
+
if (choices.has(choice.value)) faults.push(`Field "${field.name}" offers choice "${choice.value}" more than once`);
|
|
1075
|
+
choices.add(choice.value);
|
|
1076
|
+
}
|
|
1077
|
+
if (field.control === "select" && field.rule?.required === true && field.open !== true && enabled === 0) faults.push(`Field "${field.name}" is required but offers no enabled choice`);
|
|
1078
|
+
const minimum = field.rule?.minimum;
|
|
1079
|
+
if (field.control === "checkbox" && isFiniteNumber(minimum) && minimum > 0 && minimum > enabled) faults.push(`Field "${field.name}" has minimum ${minimum} but offers only ${enabled} enabled ${enabled === 1 ? "choice" : "choices"}`);
|
|
1080
|
+
}
|
|
1081
|
+
const rule = field.rule;
|
|
1082
|
+
if (rule === void 0) continue;
|
|
1083
|
+
const temporal = field.control === "date" || field.control === "time" || field.control === "datetime";
|
|
1084
|
+
if (rule.minimum !== void 0 && !appliesRule(field.control, "minimum")) faults.push(`Field "${field.name}" has minimum on ${field.control}`);
|
|
1085
|
+
else if (isString(rule.minimum) && !temporal) faults.push(`Field "${field.name}" has a string minimum on ${field.control}`);
|
|
1086
|
+
if (rule.maximum !== void 0 && !appliesRule(field.control, "maximum")) faults.push(`Field "${field.name}" has maximum on ${field.control}`);
|
|
1087
|
+
else if (isString(rule.maximum) && !temporal) faults.push(`Field "${field.name}" has a string maximum on ${field.control}`);
|
|
1088
|
+
if (isFiniteNumber(rule.minimum) && temporal) faults.push(`Field "${field.name}" has a numeric minimum on ${field.control}`);
|
|
1089
|
+
if (isFiniteNumber(rule.maximum) && temporal) faults.push(`Field "${field.name}" has a numeric maximum on ${field.control}`);
|
|
1090
|
+
if (isFiniteNumber(rule.maximum) && rule.maximum < 0 && (field.control === "text" || field.control === "editor" || field.control === "password" || field.control === "checkbox" || field.control === "file")) faults.push(`Field "${field.name}" has a negative maximum on ${field.control}`);
|
|
1091
|
+
if (rule.step !== void 0 && !appliesRule(field.control, "step")) faults.push(`Field "${field.name}" has step on ${field.control}`);
|
|
1092
|
+
if (rule.step !== void 0 && rule.step <= 0) faults.push(`Field "${field.name}" has a non-positive step`);
|
|
1093
|
+
if (rule.pattern !== void 0 && !appliesRule(field.control, "pattern")) faults.push(`Field "${field.name}" has pattern on ${field.control}`);
|
|
1094
|
+
if (rule.email === true && !appliesRule(field.control, "email")) faults.push(`Field "${field.name}" has email on ${field.control}`);
|
|
1095
|
+
if (rule.url === true && !appliesRule(field.control, "url")) faults.push(`Field "${field.name}" has url on ${field.control}`);
|
|
1096
|
+
if (rule.alphanumeric === true && !appliesRule(field.control, "alphanumeric")) faults.push(`Field "${field.name}" has alphanumeric on ${field.control}`);
|
|
1097
|
+
if (rule.integer === true && !appliesRule(field.control, "integer")) faults.push(`Field "${field.name}" has integer on ${field.control}`);
|
|
1098
|
+
if (rule.pattern !== void 0) {
|
|
1099
|
+
if (rule.pattern.length > 256) faults.push(`Field "${field.name}" has a pattern longer than 256`);
|
|
1100
|
+
else try {
|
|
1101
|
+
RegExp(rule.pattern);
|
|
1102
|
+
} catch {
|
|
1103
|
+
faults.push(`Field "${field.name}" has an invalid pattern`);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
if (isFiniteNumber(rule.minimum) && isFiniteNumber(rule.maximum) && rule.minimum > rule.maximum || isString(rule.minimum) && isString(rule.maximum) && rule.minimum > rule.maximum) faults.push(`Field "${field.name}" has minimum greater than maximum`);
|
|
1107
|
+
if (field.control === "date") {
|
|
1108
|
+
if (isString(rule.minimum) && !DATE_PATTERN.test(rule.minimum)) faults.push(`Field "${field.name}" has an invalid date minimum`);
|
|
1109
|
+
if (isString(rule.maximum) && !DATE_PATTERN.test(rule.maximum)) faults.push(`Field "${field.name}" has an invalid date maximum`);
|
|
1110
|
+
}
|
|
1111
|
+
if (field.control === "time") {
|
|
1112
|
+
if (isString(rule.minimum) && !TIME_PATTERN.test(rule.minimum)) faults.push(`Field "${field.name}" has an invalid time minimum`);
|
|
1113
|
+
if (isString(rule.maximum) && !TIME_PATTERN.test(rule.maximum)) faults.push(`Field "${field.name}" has an invalid time maximum`);
|
|
1114
|
+
}
|
|
1115
|
+
if (field.control === "datetime") {
|
|
1116
|
+
if (isString(rule.minimum) && !DATETIME_PATTERN.test(rule.minimum)) faults.push(`Field "${field.name}" has an invalid datetime minimum`);
|
|
1117
|
+
if (isString(rule.maximum) && !DATETIME_PATTERN.test(rule.maximum)) faults.push(`Field "${field.name}" has an invalid datetime maximum`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return Object.freeze(faults);
|
|
1121
|
+
}
|
|
1122
|
+
//#endregion
|
|
1123
|
+
//#region src/core/parsers.ts
|
|
1124
|
+
/**
|
|
1125
|
+
* Parse unknown wire data into an owned, semantically sound form schema.
|
|
1126
|
+
*
|
|
1127
|
+
* @param input - The unknown schema value to parse.
|
|
1128
|
+
* @returns An owned schema with custom rules removed, or `undefined` on refusal.
|
|
1129
|
+
*/
|
|
1130
|
+
function parseForm(input) {
|
|
1131
|
+
const outcome = attempt(() => {
|
|
1132
|
+
if (!isRecord(input)) return void 0;
|
|
1133
|
+
const schema = {};
|
|
1134
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
1135
|
+
if (!isString(key)) return void 0;
|
|
1136
|
+
Object.defineProperty(schema, key, {
|
|
1137
|
+
value: input[key],
|
|
1138
|
+
enumerable: true,
|
|
1139
|
+
configurable: true,
|
|
1140
|
+
writable: true
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
const fields = schema.fields;
|
|
1144
|
+
if (!isArray(fields)) return void 0;
|
|
1145
|
+
const read = readArrayEntries(fields);
|
|
1146
|
+
if (!read.success || !read.value.dense) return void 0;
|
|
1147
|
+
const copies = [];
|
|
1148
|
+
for (let index = 0; index < read.value.entries.length; index += 1) {
|
|
1149
|
+
const field = read.value.entries[index];
|
|
1150
|
+
if (!isRecord(field)) {
|
|
1151
|
+
copies.push(field);
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
const copy = {};
|
|
1155
|
+
for (const key of Reflect.ownKeys(field)) {
|
|
1156
|
+
if (!isString(key)) return void 0;
|
|
1157
|
+
Object.defineProperty(copy, key, {
|
|
1158
|
+
value: field[key],
|
|
1159
|
+
enumerable: true,
|
|
1160
|
+
configurable: true,
|
|
1161
|
+
writable: true
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
const rule = copy.rule;
|
|
1165
|
+
if (isRecord(rule)) {
|
|
1166
|
+
const projected = {};
|
|
1167
|
+
for (const key of Reflect.ownKeys(rule)) {
|
|
1168
|
+
if (!isString(key)) return void 0;
|
|
1169
|
+
if (key !== "custom") Object.defineProperty(projected, key, {
|
|
1170
|
+
value: rule[key],
|
|
1171
|
+
enumerable: true,
|
|
1172
|
+
configurable: true,
|
|
1173
|
+
writable: true
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
copy.rule = projected;
|
|
1177
|
+
}
|
|
1178
|
+
copies.push(copy);
|
|
1179
|
+
}
|
|
1180
|
+
schema.fields = copies;
|
|
1181
|
+
if (!isFormSchema(schema) || auditSchema(schema).length !== 0) return void 0;
|
|
1182
|
+
const parsed = serializeForm(schema);
|
|
1183
|
+
return isFormSchema(parsed) && auditSchema(parsed).length === 0 ? parsed : void 0;
|
|
1184
|
+
});
|
|
1185
|
+
return outcome.success ? outcome.value : void 0;
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Parse one answer against its field control.
|
|
1189
|
+
*
|
|
1190
|
+
* @param field - The field that defines the accepted value.
|
|
1191
|
+
* @param input - The unknown value to parse.
|
|
1192
|
+
* @returns The typed or lexically coerced field value, or `undefined` on refusal.
|
|
1193
|
+
*/
|
|
1194
|
+
function parseValue(field, input) {
|
|
1195
|
+
const outcome = attempt(() => {
|
|
1196
|
+
if (matchesField(field, input)) return cloneValue(input);
|
|
1197
|
+
if (field.control === "number") {
|
|
1198
|
+
if (isString(input) && input.length > 65536) return void 0;
|
|
1199
|
+
const value = parseNumber(input);
|
|
1200
|
+
return value !== void 0 && matchesField(field, value) ? value : void 0;
|
|
1201
|
+
}
|
|
1202
|
+
if (field.control === "confirm") {
|
|
1203
|
+
if (input === "true") return true;
|
|
1204
|
+
if (input === "false") return false;
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
return outcome.success ? outcome.value : void 0;
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Parse a strict answer record against the fields declared by a schema.
|
|
1211
|
+
*
|
|
1212
|
+
* @param schema - The schema that owns the accepted field names and controls.
|
|
1213
|
+
* @param input - The unknown answer record to parse.
|
|
1214
|
+
* @returns An owned answer record, or `undefined` when any key or value is refused.
|
|
1215
|
+
*/
|
|
1216
|
+
function parseValues(schema, input) {
|
|
1217
|
+
const outcome = attempt(() => {
|
|
1218
|
+
if (!isRecord(input)) return void 0;
|
|
1219
|
+
const values = {};
|
|
1220
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
1221
|
+
if (!isString(key) || !Object.hasOwn(input, key)) return void 0;
|
|
1222
|
+
const field = schema.fields.find((candidate) => candidate.name === key);
|
|
1223
|
+
if (field === void 0) return void 0;
|
|
1224
|
+
const value = parseValue(field, input[key]);
|
|
1225
|
+
if (value === void 0) return void 0;
|
|
1226
|
+
Object.defineProperty(values, key, {
|
|
1227
|
+
value,
|
|
1228
|
+
enumerable: true,
|
|
1229
|
+
configurable: false,
|
|
1230
|
+
writable: false
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
return Object.freeze(values);
|
|
1234
|
+
});
|
|
1235
|
+
return outcome.success ? outcome.value : void 0;
|
|
1236
|
+
}
|
|
1237
|
+
//#endregion
|
|
1238
|
+
//#region src/core/Form.ts
|
|
1239
|
+
/**
|
|
1240
|
+
* A form: a schema, the answers given against it, and the errors they carry.
|
|
1241
|
+
*
|
|
1242
|
+
* @remarks
|
|
1243
|
+
* The form owns its schema, so a later edit to the schema the caller passed changes nothing here.
|
|
1244
|
+
*
|
|
1245
|
+
* `errors` is recomputed at construction and after every mutation whose evaluation completes,
|
|
1246
|
+
* and the `validate` event fires exactly when that list's content changes. A throwing custom
|
|
1247
|
+
* validator escapes after any preceding state changes and leaves the prior error list in place.
|
|
1248
|
+
* There is no separate check.
|
|
1249
|
+
*
|
|
1250
|
+
* `valid` and `dirty` are derived on read from the error list and answers respectively, never
|
|
1251
|
+
* stored.
|
|
1252
|
+
*
|
|
1253
|
+
* @example
|
|
1254
|
+
* ```ts
|
|
1255
|
+
* const form = new Form({
|
|
1256
|
+
* fields: [{ control: 'text', name: 'email', rule: { required: true, email: true } }],
|
|
1257
|
+
* })
|
|
1258
|
+
*
|
|
1259
|
+
* form.fill('email', 'ada@example.com')
|
|
1260
|
+
* const result = form.submit()
|
|
1261
|
+
* if (result.success) await form.answer
|
|
1262
|
+
* ```
|
|
1263
|
+
*/
|
|
1264
|
+
var Form = class {
|
|
1265
|
+
#emitter;
|
|
1266
|
+
#schema;
|
|
1267
|
+
#messages;
|
|
1268
|
+
#baseline;
|
|
1269
|
+
#values;
|
|
1270
|
+
#touched = /* @__PURE__ */ new Set();
|
|
1271
|
+
#disabled = /* @__PURE__ */ new Map();
|
|
1272
|
+
#invalidations = /* @__PURE__ */ new Map();
|
|
1273
|
+
#resolvers = Promise.withResolvers();
|
|
1274
|
+
#errors = Object.freeze([]);
|
|
1275
|
+
#status = "editing";
|
|
1276
|
+
#batchDepth = 0;
|
|
1277
|
+
#evaluation = 0;
|
|
1278
|
+
#pending = false;
|
|
1279
|
+
/**
|
|
1280
|
+
* Open a form against a schema.
|
|
1281
|
+
*
|
|
1282
|
+
* @param schema - The form to ask. It is copied, and the copy is what the form asks.
|
|
1283
|
+
* @param options - The form's settings.
|
|
1284
|
+
* @throws A {@link FormError} coded `SCHEMA` when the schema is malformed, `FIELD` when
|
|
1285
|
+
* `options.values` names a field the schema does not declare, and `CONTROL` when a seeded
|
|
1286
|
+
* value is one its field's control cannot hold.
|
|
1287
|
+
*/
|
|
1288
|
+
constructor(schema, options) {
|
|
1289
|
+
const problems = isFormSchema(schema) ? auditSchema(schema) : ["The schema is not a form schema"];
|
|
1290
|
+
if (problems.length > 0) throw new FormError("SCHEMA", `The form schema is unusable: ${problems.join("; ")}`, { problems: [...problems] });
|
|
1291
|
+
this.#schema = cloneFormSchema(schema);
|
|
1292
|
+
this.#messages = options?.messages === void 0 ? void 0 : Object.freeze({ ...options.messages });
|
|
1293
|
+
const baseline = {};
|
|
1294
|
+
for (const [name, value] of Object.entries(computeDefaults(this.#schema))) Object.defineProperty(baseline, name, {
|
|
1295
|
+
value,
|
|
1296
|
+
enumerable: true,
|
|
1297
|
+
configurable: true,
|
|
1298
|
+
writable: true
|
|
1299
|
+
});
|
|
1300
|
+
for (const [name, value] of Object.entries(options?.values ?? {})) {
|
|
1301
|
+
const field = this.#requireField(name);
|
|
1302
|
+
if (!matchesField(field, value)) throw new FormError("CONTROL", `The ${field.control} field "${name}" cannot hold that value`, {
|
|
1303
|
+
field: name,
|
|
1304
|
+
control: field.control
|
|
1305
|
+
});
|
|
1306
|
+
Object.defineProperty(baseline, name, {
|
|
1307
|
+
value: cloneValue(value),
|
|
1308
|
+
enumerable: true,
|
|
1309
|
+
configurable: true,
|
|
1310
|
+
writable: true
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
this.#baseline = Object.freeze(baseline);
|
|
1314
|
+
this.#values = {};
|
|
1315
|
+
for (const [name, value] of Object.entries(baseline)) Object.defineProperty(this.#values, name, {
|
|
1316
|
+
value,
|
|
1317
|
+
enumerable: true,
|
|
1318
|
+
configurable: true,
|
|
1319
|
+
writable: true
|
|
1320
|
+
});
|
|
1321
|
+
this.#resolvers.promise.catch(() => void 0);
|
|
1322
|
+
this.#emitter = new Emitter(options);
|
|
1323
|
+
this.#evaluate();
|
|
1324
|
+
}
|
|
1325
|
+
/** The form's event emitter. */
|
|
1326
|
+
get emitter() {
|
|
1327
|
+
return this.#emitter;
|
|
1328
|
+
}
|
|
1329
|
+
/** The schema this form asks, owned and frozen. */
|
|
1330
|
+
get schema() {
|
|
1331
|
+
return this.#schema;
|
|
1332
|
+
}
|
|
1333
|
+
/** The answers held right now. */
|
|
1334
|
+
get values() {
|
|
1335
|
+
const values = {};
|
|
1336
|
+
for (const name of Object.keys(this.#values)) Object.defineProperty(values, name, {
|
|
1337
|
+
value: this.#values[name],
|
|
1338
|
+
enumerable: true,
|
|
1339
|
+
configurable: true,
|
|
1340
|
+
writable: true
|
|
1341
|
+
});
|
|
1342
|
+
return Object.freeze(values);
|
|
1343
|
+
}
|
|
1344
|
+
/** The answers the form opened with. */
|
|
1345
|
+
get baseline() {
|
|
1346
|
+
return this.#baseline;
|
|
1347
|
+
}
|
|
1348
|
+
/** Every error the last completed evaluation produced. */
|
|
1349
|
+
get errors() {
|
|
1350
|
+
return this.#errors;
|
|
1351
|
+
}
|
|
1352
|
+
/** The names of the fields somebody has visited. */
|
|
1353
|
+
get touched() {
|
|
1354
|
+
return new Set(this.#touched);
|
|
1355
|
+
}
|
|
1356
|
+
/** The names of the fields currently out of the form. */
|
|
1357
|
+
get disabled() {
|
|
1358
|
+
const disabled = /* @__PURE__ */ new Set();
|
|
1359
|
+
for (const field of this.#schema.fields) if ((this.#disabled.get(field.name) ?? field.disabled === true) === true) disabled.add(field.name);
|
|
1360
|
+
return disabled;
|
|
1361
|
+
}
|
|
1362
|
+
/** Where the form sits in its life. */
|
|
1363
|
+
get status() {
|
|
1364
|
+
return this.#status;
|
|
1365
|
+
}
|
|
1366
|
+
/** Whether the last completed evaluation found no error. */
|
|
1367
|
+
get valid() {
|
|
1368
|
+
return this.#errors.length === 0;
|
|
1369
|
+
}
|
|
1370
|
+
/** Whether any answer has moved since the form opened. */
|
|
1371
|
+
get dirty() {
|
|
1372
|
+
return !matchesValues(this.values, this.#baseline);
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* The answers, once the form settles.
|
|
1376
|
+
*
|
|
1377
|
+
* @remarks
|
|
1378
|
+
* It resolves with the submitted values on the first valid submit, and rejects with a
|
|
1379
|
+
* {@link FormError} coded `ABANDONED` when teardown abandons the form before it settles.
|
|
1380
|
+
*/
|
|
1381
|
+
get answer() {
|
|
1382
|
+
return this.#resolvers.promise;
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Find one field by name.
|
|
1386
|
+
*
|
|
1387
|
+
* @param name - The field's name.
|
|
1388
|
+
* @returns The field, or `undefined` when the schema declares no such name.
|
|
1389
|
+
*/
|
|
1390
|
+
field(name) {
|
|
1391
|
+
return this.#schema.fields.find((field) => field.name === name);
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Answer one field or several.
|
|
1395
|
+
*
|
|
1396
|
+
* @param input - One field's name, or the answers to write keyed by field name.
|
|
1397
|
+
* @param value - The answer to write when `input` names one field.
|
|
1398
|
+
* @throws A {@link FormError} coded `SETTLED` or `ABANDONED` when the form has ended, `FIELD`
|
|
1399
|
+
* when a name is not declared, and `CONTROL` when a value is one its control cannot hold.
|
|
1400
|
+
* Every answer is checked before any is written, so a refused write changes nothing.
|
|
1401
|
+
*/
|
|
1402
|
+
fill(input, value) {
|
|
1403
|
+
this.#gate();
|
|
1404
|
+
const entries = isString(input) ? [[input, value]] : Object.entries(input);
|
|
1405
|
+
for (const [name, answer] of entries) {
|
|
1406
|
+
const field = this.#requireField(name);
|
|
1407
|
+
if (answer !== void 0 && !matchesField(field, answer)) throw new FormError("CONTROL", `The ${field.control} field "${name}" cannot hold that value`, {
|
|
1408
|
+
field: name,
|
|
1409
|
+
control: field.control
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
this.#batch(() => {
|
|
1413
|
+
const moved = [];
|
|
1414
|
+
for (const [name, answer] of entries) {
|
|
1415
|
+
if (this.#differs(name, answer)) moved.push(name);
|
|
1416
|
+
if (answer === void 0) delete this.#values[name];
|
|
1417
|
+
else Object.defineProperty(this.#values, name, {
|
|
1418
|
+
value: cloneValue(answer),
|
|
1419
|
+
enumerable: true,
|
|
1420
|
+
configurable: true,
|
|
1421
|
+
writable: true
|
|
1422
|
+
});
|
|
1423
|
+
this.#invalidations.delete(name);
|
|
1424
|
+
}
|
|
1425
|
+
for (const name of moved) {
|
|
1426
|
+
const answer = Object.hasOwn(this.#values, name) ? this.#values[name] : void 0;
|
|
1427
|
+
this.#emitter.emit("fill", name, answer);
|
|
1428
|
+
}
|
|
1429
|
+
if (this.#evaluate()) this.#emitter.emit("validate", this.#errors);
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Record that somebody has visited a field.
|
|
1434
|
+
*
|
|
1435
|
+
* @param name - The field's name.
|
|
1436
|
+
* @throws A {@link FormError} coded `SETTLED` or `ABANDONED` when the form has ended, and
|
|
1437
|
+
* `FIELD` when the schema declares no such name.
|
|
1438
|
+
*/
|
|
1439
|
+
touch(name) {
|
|
1440
|
+
this.#gate();
|
|
1441
|
+
this.#requireField(name);
|
|
1442
|
+
this.#touched.add(name);
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Fail a field from outside, for what the rules cannot see.
|
|
1446
|
+
*
|
|
1447
|
+
* @param name - The field's name.
|
|
1448
|
+
* @param message - What to tell the person.
|
|
1449
|
+
* @remarks
|
|
1450
|
+
* One field holds one external failure: a second call replaces the first. The failure lasts
|
|
1451
|
+
* until that field is filled again or the form is cleared.
|
|
1452
|
+
* @throws A {@link FormError} coded `SETTLED` or `ABANDONED` when the form has ended, and
|
|
1453
|
+
* `FIELD` when the schema declares no such name.
|
|
1454
|
+
*/
|
|
1455
|
+
invalidate(name, message) {
|
|
1456
|
+
this.#gate();
|
|
1457
|
+
this.#requireField(name);
|
|
1458
|
+
this.#batch(() => {
|
|
1459
|
+
this.#invalidations.set(name, message);
|
|
1460
|
+
if (this.#evaluate()) this.#emitter.emit("validate", this.#errors);
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Take one or more fields out of the form.
|
|
1465
|
+
*
|
|
1466
|
+
* @param input - One field name, several names, or absence to select every field.
|
|
1467
|
+
* @throws A {@link FormError} coded `SETTLED` or `ABANDONED` when the form has ended, and
|
|
1468
|
+
* `FIELD` when the schema declares no requested name. Every name is checked before any field
|
|
1469
|
+
* moves.
|
|
1470
|
+
*/
|
|
1471
|
+
disable(input) {
|
|
1472
|
+
this.#gate();
|
|
1473
|
+
this.#change(input, true);
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Put one or more fields back into the form.
|
|
1477
|
+
*
|
|
1478
|
+
* @param input - One field name, several names, or absence to select every field.
|
|
1479
|
+
* @throws A {@link FormError} coded `SETTLED` or `ABANDONED` when the form has ended, and
|
|
1480
|
+
* `FIELD` when the schema declares no requested name. Every name is checked before any field
|
|
1481
|
+
* moves.
|
|
1482
|
+
*/
|
|
1483
|
+
enable(input) {
|
|
1484
|
+
this.#gate();
|
|
1485
|
+
this.#change(input, false);
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Check every answer and settle the form when they all pass.
|
|
1489
|
+
*
|
|
1490
|
+
* @returns The values on success, or every error that stopped them.
|
|
1491
|
+
* @remarks
|
|
1492
|
+
* A failed submit marks every enabled field touched, so a renderer can show the errors the
|
|
1493
|
+
* person has not reached yet. A disabled field is neither checked nor submitted. When a changed
|
|
1494
|
+
* evaluation notifies listeners and a listener writes, submit evaluates once more after those
|
|
1495
|
+
* listeners return and decides from that state. Listener work that settled the form wins: that
|
|
1496
|
+
* settlement is what this call returns, with no further evaluation, resolution, or `submit`
|
|
1497
|
+
* emission. An evaluation that already failed refuses with the list it checked, even when a
|
|
1498
|
+
* listener repaired or disabled the field that failed. An evaluation that passed decides from the
|
|
1499
|
+
* state the drain left, which is why one further evaluation bounds the drain rather than a
|
|
1500
|
+
* fixpoint loop.
|
|
1501
|
+
* @throws A {@link FormError} coded `SETTLED` or `ABANDONED` when the form has ended.
|
|
1502
|
+
*/
|
|
1503
|
+
submit() {
|
|
1504
|
+
this.#gate();
|
|
1505
|
+
return this.#batch(() => {
|
|
1506
|
+
const changed = this.#evaluate();
|
|
1507
|
+
const checked = this.#errors;
|
|
1508
|
+
const failed = checked.length > 0;
|
|
1509
|
+
const evaluation = this.#evaluation;
|
|
1510
|
+
if (changed) {
|
|
1511
|
+
this.#emitter.emit("validate", this.#errors);
|
|
1512
|
+
const settlement = this.#readSettlement();
|
|
1513
|
+
if (settlement !== void 0) return settlement;
|
|
1514
|
+
if (this.#evaluation !== evaluation && this.#evaluate()) {
|
|
1515
|
+
this.#emitter.emit("validate", this.#errors);
|
|
1516
|
+
const drained = this.#readSettlement();
|
|
1517
|
+
if (drained !== void 0) return drained;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
const errors = failed ? checked : this.#errors;
|
|
1521
|
+
const stopped = errors.length > 0;
|
|
1522
|
+
if (stopped) {
|
|
1523
|
+
const disabled = this.disabled;
|
|
1524
|
+
for (const field of this.#schema.fields) if (!disabled.has(field.name)) this.#touched.add(field.name);
|
|
1525
|
+
}
|
|
1526
|
+
if (stopped) return {
|
|
1527
|
+
success: false,
|
|
1528
|
+
error: errors
|
|
1529
|
+
};
|
|
1530
|
+
const answers = this.#snapshot();
|
|
1531
|
+
this.#status = "settled";
|
|
1532
|
+
this.#resolvers.resolve(answers);
|
|
1533
|
+
this.#emitter.emit("submit", answers);
|
|
1534
|
+
return {
|
|
1535
|
+
success: true,
|
|
1536
|
+
value: answers
|
|
1537
|
+
};
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Return every answer to the ones the form opened with: the schema's defaults, overlaid with
|
|
1542
|
+
* any seeded `values`. Reset the runtime disabled state to the schema's declarations.
|
|
1543
|
+
*
|
|
1544
|
+
* @throws A {@link FormError} coded `SETTLED` or `ABANDONED` when the form has ended.
|
|
1545
|
+
*/
|
|
1546
|
+
clear() {
|
|
1547
|
+
this.#gate();
|
|
1548
|
+
this.#batch(() => {
|
|
1549
|
+
for (const name of Object.keys(this.#values)) delete this.#values[name];
|
|
1550
|
+
for (const [name, value] of Object.entries(this.#baseline)) Object.defineProperty(this.#values, name, {
|
|
1551
|
+
value,
|
|
1552
|
+
enumerable: true,
|
|
1553
|
+
configurable: true,
|
|
1554
|
+
writable: true
|
|
1555
|
+
});
|
|
1556
|
+
this.#touched.clear();
|
|
1557
|
+
this.#disabled.clear();
|
|
1558
|
+
this.#invalidations.clear();
|
|
1559
|
+
const changed = this.#evaluate();
|
|
1560
|
+
this.#emitter.emit("clear");
|
|
1561
|
+
if (changed) this.#emitter.emit("validate", this.#errors);
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Tear the form down, abandoning it when it has not settled.
|
|
1566
|
+
*
|
|
1567
|
+
* @remarks
|
|
1568
|
+
* Destroying twice does nothing the second time. A settled form keeps its `settled` status and
|
|
1569
|
+
* announces nothing. A request from inside a listener defers teardown until the outermost
|
|
1570
|
+
* mutation batch closes, so an in-flight settlement can win and leave the form `settled` rather
|
|
1571
|
+
* than `abandoned`. Every getter keeps answering afterwards; every write is refused.
|
|
1572
|
+
*/
|
|
1573
|
+
destroy() {
|
|
1574
|
+
if (this.#pending) return;
|
|
1575
|
+
this.#pending = true;
|
|
1576
|
+
if (this.#batchDepth === 0) this.#teardown();
|
|
1577
|
+
}
|
|
1578
|
+
#gate() {
|
|
1579
|
+
if (this.#status === "settled") throw new FormError("SETTLED", "The form has settled and cannot change");
|
|
1580
|
+
if (this.#status === "abandoned" || this.#pending) throw new FormError("ABANDONED", "The form was abandoned and cannot change");
|
|
1581
|
+
}
|
|
1582
|
+
#requireField(name) {
|
|
1583
|
+
const field = this.field(name);
|
|
1584
|
+
if (field === void 0) throw new FormError("FIELD", `The schema declares no field named "${name}"`, { field: name });
|
|
1585
|
+
return field;
|
|
1586
|
+
}
|
|
1587
|
+
#change(input, disabled) {
|
|
1588
|
+
const names = new Set(input === void 0 ? this.#schema.fields.map((field) => field.name) : isString(input) ? [input] : input);
|
|
1589
|
+
for (const name of names) this.#requireField(name);
|
|
1590
|
+
const moved = [];
|
|
1591
|
+
for (const field of this.#schema.fields) {
|
|
1592
|
+
const active = names.has(field.name);
|
|
1593
|
+
const current = this.#disabled.get(field.name) ?? field.disabled === true;
|
|
1594
|
+
if (active && current !== disabled) moved.push(field.name);
|
|
1595
|
+
}
|
|
1596
|
+
if (moved.length === 0) return;
|
|
1597
|
+
this.#batch(() => {
|
|
1598
|
+
for (const name of names) this.#disabled.set(name, disabled);
|
|
1599
|
+
for (const name of moved) if (disabled) this.#emitter.emit("disable", name);
|
|
1600
|
+
else this.#emitter.emit("enable", name);
|
|
1601
|
+
if (this.#evaluate()) this.#emitter.emit("validate", this.#errors);
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
#evaluate() {
|
|
1605
|
+
const disabled = this.disabled;
|
|
1606
|
+
const options = this.#messages === void 0 ? { disabled } : {
|
|
1607
|
+
messages: this.#messages,
|
|
1608
|
+
disabled
|
|
1609
|
+
};
|
|
1610
|
+
const errors = [...evaluateForm(this.#schema, this.values, options)];
|
|
1611
|
+
for (const [field, message] of this.#invalidations) if (!disabled.has(field)) errors.push(Object.freeze({
|
|
1612
|
+
field,
|
|
1613
|
+
message
|
|
1614
|
+
}));
|
|
1615
|
+
const previous = this.#errors;
|
|
1616
|
+
const next = Object.freeze(errors);
|
|
1617
|
+
this.#errors = next;
|
|
1618
|
+
const changed = next.length !== previous.length || next.some((error, index) => {
|
|
1619
|
+
const before = previous[index];
|
|
1620
|
+
return before === void 0 || before.field !== error.field || before.message !== error.message || before.rule !== error.rule;
|
|
1621
|
+
});
|
|
1622
|
+
this.#evaluation += 1;
|
|
1623
|
+
return changed;
|
|
1624
|
+
}
|
|
1625
|
+
#differs(name, value) {
|
|
1626
|
+
const current = Object.hasOwn(this.#values, name) ? this.#values[name] : void 0;
|
|
1627
|
+
if (value === void 0) return current !== void 0;
|
|
1628
|
+
if (current === void 0) return true;
|
|
1629
|
+
return !matchesValue(current, value);
|
|
1630
|
+
}
|
|
1631
|
+
#readSettlement() {
|
|
1632
|
+
if (this.#status === "settled") return {
|
|
1633
|
+
success: true,
|
|
1634
|
+
value: this.#snapshot()
|
|
1635
|
+
};
|
|
1636
|
+
if (this.#status === "abandoned") this.#gate();
|
|
1637
|
+
}
|
|
1638
|
+
#snapshot() {
|
|
1639
|
+
const answers = {};
|
|
1640
|
+
const disabled = this.disabled;
|
|
1641
|
+
for (const field of this.#schema.fields) {
|
|
1642
|
+
const value = Object.hasOwn(this.#values, field.name) ? this.#values[field.name] : void 0;
|
|
1643
|
+
if (!disabled.has(field.name) && value !== void 0) Object.defineProperty(answers, field.name, {
|
|
1644
|
+
value,
|
|
1645
|
+
enumerable: true,
|
|
1646
|
+
configurable: true,
|
|
1647
|
+
writable: true
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
return Object.freeze(answers);
|
|
1651
|
+
}
|
|
1652
|
+
#batch(callback) {
|
|
1653
|
+
this.#batchDepth += 1;
|
|
1654
|
+
try {
|
|
1655
|
+
return callback();
|
|
1656
|
+
} finally {
|
|
1657
|
+
this.#batchDepth -= 1;
|
|
1658
|
+
if (this.#batchDepth === 0 && this.#pending) this.#teardown();
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
#teardown() {
|
|
1662
|
+
if (this.#status === "editing") {
|
|
1663
|
+
this.#status = "abandoned";
|
|
1664
|
+
this.#resolvers.reject(new FormError("ABANDONED", "The form was destroyed before it settled"));
|
|
1665
|
+
this.#emitter.emit("abandon");
|
|
1666
|
+
}
|
|
1667
|
+
this.#emitter.destroy();
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1670
|
+
//#endregion
|
|
1671
|
+
//#region src/core/factories.ts
|
|
1672
|
+
/**
|
|
1673
|
+
* Open a form against a schema.
|
|
1674
|
+
*
|
|
1675
|
+
* @param schema - The form to ask. It is copied, and the copy is what the form asks.
|
|
1676
|
+
* @param options - The form's settings.
|
|
1677
|
+
* @returns A form open for answers.
|
|
1678
|
+
* @remarks
|
|
1679
|
+
* Prefer this at a call site that only needs {@link FormInterface}. `new Form(...)` is the same
|
|
1680
|
+
* construction and is what a class holding a form as its own field reaches for.
|
|
1681
|
+
* @throws A {@link FormError} coded `SCHEMA` when the schema is malformed, `FIELD` when
|
|
1682
|
+
* `options.values` names a field the schema does not declare, and `CONTROL` when a seeded value
|
|
1683
|
+
* is one its field's control cannot hold.
|
|
1684
|
+
* @example
|
|
1685
|
+
* ```ts
|
|
1686
|
+
* const form = createForm({
|
|
1687
|
+
* label: 'Sign up',
|
|
1688
|
+
* fields: [
|
|
1689
|
+
* { control: 'text', name: 'email', label: 'Email', rule: { required: true, email: true } },
|
|
1690
|
+
* { control: 'confirm', name: 'terms', label: 'I accept the terms', rule: { required: true } },
|
|
1691
|
+
* ],
|
|
1692
|
+
* })
|
|
1693
|
+
*
|
|
1694
|
+
* form.fill({ email: 'ada@example.com', terms: true })
|
|
1695
|
+
* form.submit() // { success: true, value: { email: 'ada@example.com', terms: true } }
|
|
1696
|
+
* ```
|
|
1697
|
+
*/
|
|
1698
|
+
function createForm(schema, options) {
|
|
1699
|
+
return new Form(schema, options);
|
|
1700
|
+
}
|
|
1701
|
+
//#endregion
|
|
1702
|
+
export { ALPHANUMERIC_PATTERN, CHOICE_LIMIT, COLOR_PATTERN, DATETIME_PATTERN, DATE_PATTERN, EMAIL_PATTERN, FIELD_CONTROLS, FIELD_LIMIT, FORM_STATUSES, Form, FormError, GROUP_LIMIT, INTEGER_PATTERN, LIST_LIMIT, NAME_LIMIT, NODE_LIMIT, PATTERN_LIMIT, RULE_MESSAGES, STRING_LIMIT, TEXT_LIMIT, TIME_PATTERN, URL_PATTERN, appliesRule, auditSchema, cloneChoices, cloneFormField, cloneFormSchema, cloneValue, computeDefaults, createForm, evaluateField, evaluateForm, extractChanges, extractGroups, formatMessage, isFieldChoice, isFieldControl, isFieldError, isFieldRule, isFieldValue, isFormError, isFormField, isFormGroup, isFormSchema, isFormStatus, isFormValues, matchesAnswer, matchesField, matchesValue, matchesValues, parseForm, parseValue, parseValues, serializeForm };
|
|
1703
|
+
|
|
1704
|
+
//# sourceMappingURL=index.js.map
|