@orkestrel/form 0.0.8 → 0.0.10

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.
@@ -0,0 +1,916 @@
1
+ import { attempt, isArray, isBoolean, isFiniteNumber, isInstance, isString, readArrayEntries } from "@orkestrel/contract";
2
+ import { Emitter } from "@orkestrel/emitter";
3
+ //#region src/browser/errors.ts
4
+ /** Reports an incompatible control, overlapping submission, or native DOM failure. */
5
+ var FormBindingError = class extends Error {
6
+ code;
7
+ context;
8
+ /**
9
+ * Creates a native binding refusal.
10
+ * @param code - The refusal category.
11
+ * @param message - The explanation.
12
+ * @param context - The associated field or cause.
13
+ */
14
+ constructor(code, message, context) {
15
+ super(message);
16
+ this.name = "FormBindingError";
17
+ this.code = code;
18
+ this.context = context === void 0 ? void 0 : Object.freeze({ ...context });
19
+ }
20
+ };
21
+ /**
22
+ * Checks whether a value is a native binding refusal.
23
+ * @param value - The value to inspect.
24
+ * @returns True if the value is a native binding refusal; false otherwise.
25
+ * @example
26
+ * ```ts
27
+ * isFormBindingError(new FormBindingError('CONTROL', 'Duplicate text controls')) // true
28
+ * ```
29
+ */
30
+ function isFormBindingError(value) {
31
+ return isInstance(value, FormBindingError);
32
+ }
33
+ Object.freeze([
34
+ "text",
35
+ "editor",
36
+ "password",
37
+ "number",
38
+ "date",
39
+ "time",
40
+ "datetime",
41
+ "color",
42
+ "confirm",
43
+ "select",
44
+ "checkbox",
45
+ "file"
46
+ ]);
47
+ /** Lists the members every field declares, whatever its control. */
48
+ var FIELD_BASE_KEYS = Object.freeze([
49
+ "control",
50
+ "name",
51
+ "label",
52
+ "help",
53
+ "group",
54
+ "hidden",
55
+ "disabled",
56
+ "locked",
57
+ "rule",
58
+ "meta"
59
+ ]);
60
+ Object.freeze({
61
+ text: Object.freeze([
62
+ ...FIELD_BASE_KEYS,
63
+ "default",
64
+ "placeholder"
65
+ ]),
66
+ editor: Object.freeze([
67
+ ...FIELD_BASE_KEYS,
68
+ "default",
69
+ "placeholder"
70
+ ]),
71
+ password: Object.freeze([...FIELD_BASE_KEYS, "mask"]),
72
+ number: Object.freeze([
73
+ ...FIELD_BASE_KEYS,
74
+ "default",
75
+ "placeholder"
76
+ ]),
77
+ date: Object.freeze([...FIELD_BASE_KEYS, "default"]),
78
+ time: Object.freeze([...FIELD_BASE_KEYS, "default"]),
79
+ datetime: Object.freeze([...FIELD_BASE_KEYS, "default"]),
80
+ color: Object.freeze([...FIELD_BASE_KEYS, "default"]),
81
+ confirm: Object.freeze([...FIELD_BASE_KEYS, "default"]),
82
+ select: Object.freeze([
83
+ ...FIELD_BASE_KEYS,
84
+ "choices",
85
+ "default",
86
+ "open"
87
+ ]),
88
+ checkbox: Object.freeze([
89
+ ...FIELD_BASE_KEYS,
90
+ "choices",
91
+ "default"
92
+ ]),
93
+ file: Object.freeze([
94
+ ...FIELD_BASE_KEYS,
95
+ "accept",
96
+ "multiple"
97
+ ])
98
+ });
99
+ Object.freeze([
100
+ "editing",
101
+ "settled",
102
+ "abandoned"
103
+ ]);
104
+ Object.freeze({
105
+ required: "This field is required",
106
+ minimum: "Must be at least {limit}",
107
+ maximum: "Must be at most {limit}",
108
+ step: "Must be a multiple of {limit}",
109
+ pattern: "Must match the required format",
110
+ email: "Must be a valid email address",
111
+ url: "Must be a valid URL",
112
+ integer: "Must be an integer",
113
+ alphanumeric: "Must contain only letters and numbers"
114
+ });
115
+ Object.freeze(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
116
+ Object.freeze(/^https?:\/\/[^\s]+$/);
117
+ Object.freeze(/^[A-Za-z0-9]+$/);
118
+ Object.freeze(/^[+-]?\d+$/);
119
+ /** Matches a six-digit hexadecimal color string. */
120
+ var COLOR_PATTERN = Object.freeze(/^#[0-9A-Fa-f]{6}$/);
121
+ /** Matches an ISO calendar date string in `YYYY-MM-DD` form. */
122
+ var DATE_PATTERN = Object.freeze(/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/);
123
+ /** Matches a 24-hour time string with optional seconds. */
124
+ var TIME_PATTERN = Object.freeze(/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/);
125
+ /** Matches an ISO local date and time string with optional seconds. */
126
+ 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)?$/);
127
+ //#endregion
128
+ //#region src/core/helpers.ts
129
+ /**
130
+ * Checks whether a value has the shape required by one field control.
131
+ *
132
+ * @remarks
133
+ * Every write and every seeded value passes through this gate, and it reads `STRING_LIMIT` and
134
+ * `LIST_LIMIT` before it consults the control, so no regular expression sees an over-long value.
135
+ *
136
+ * @param field - The field that owns the value.
137
+ * @param value - The unknown value to inspect.
138
+ * @returns True if the control can hold the value; false otherwise.
139
+ */
140
+ function matchesField(field, value) {
141
+ if (isString(value) && value.length > 65536) return false;
142
+ let entries;
143
+ if (isArray(value)) {
144
+ const length = attempt(() => value.length);
145
+ if (!length.success || length.value > 1024) return false;
146
+ const read = readArrayEntries(value);
147
+ if (!read.success || !read.value.dense) return false;
148
+ entries = read.value.entries;
149
+ if (entries.some((entry) => !isString(entry) || entry.length > 65536)) return false;
150
+ }
151
+ switch (field.control) {
152
+ case "text":
153
+ case "editor":
154
+ case "password": return isString(value);
155
+ case "number": return isFiniteNumber(value);
156
+ case "date": return isString(value) && DATE_PATTERN.test(value);
157
+ case "time": return isString(value) && TIME_PATTERN.test(value);
158
+ case "datetime": return isString(value) && DATETIME_PATTERN.test(value);
159
+ case "color": return isString(value) && COLOR_PATTERN.test(value);
160
+ case "confirm": return isBoolean(value);
161
+ 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));
162
+ 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;
163
+ case "file": return entries !== void 0 && (field.multiple === true || entries.length <= 1);
164
+ }
165
+ }
166
+ /**
167
+ * Decides whether a raw binding value projects to an answered field.
168
+ *
169
+ * @remarks
170
+ * Bind with `fill(name, matchesAnswer(raw) ? raw : undefined)`. This projection treats an absent
171
+ * value and a string containing only whitespace as unanswered. Every other field value is an
172
+ * answer, including an empty list, `false`, and zero. Core evaluation does not use this projection:
173
+ * its `required` rule remains presence-only.
174
+ *
175
+ * @param value - The raw field value, or absence.
176
+ * @returns True if the binding preserves the value as an answer; false otherwise.
177
+ */
178
+ function matchesAnswer(value) {
179
+ return value !== void 0 && (!isString(value) || value.trim().length > 0);
180
+ }
181
+ /**
182
+ * Compares two field values by scalar identity or ordered list content.
183
+ *
184
+ * @param a - The first field value.
185
+ * @param b - The second field value.
186
+ * @returns True if both values contain the same answer; false otherwise.
187
+ */
188
+ function matchesValue(a, b) {
189
+ if (isArray(a) || isArray(b)) return isArray(a) && isArray(b) && a.length === b.length && a.every((entry, index) => entry === b[index]);
190
+ return a === b;
191
+ }
192
+ //#endregion
193
+ //#region src/browser/helpers.ts
194
+ /**
195
+ * Checks a native control population against a field's declared value shape.
196
+ * @param field - The declared field.
197
+ * @param controls - Associated controls sharing its name.
198
+ * @returns True if the population has an unambiguous compatible shape; false otherwise.
199
+ * @example
200
+ * ```ts
201
+ * matchesFormControls({ control: 'text', name: 'name' }, [document.createElement('input')]) // true
202
+ * ```
203
+ */
204
+ function matchesFormControls(field, controls) {
205
+ if (controls.length === 0) return true;
206
+ if (controls.length > 1 && field.control !== "checkbox" && field.control !== "select") return false;
207
+ return controls.every((control) => {
208
+ if (control instanceof HTMLTextAreaElement) return field.control === "editor" && controls.length === 1;
209
+ if (control instanceof HTMLSelectElement) return controls.length === 1 && (control.multiple ? field.control === "checkbox" : field.control === "select");
210
+ switch (field.control) {
211
+ case "text": return [
212
+ "text",
213
+ "search",
214
+ "email",
215
+ "url",
216
+ "tel",
217
+ "hidden"
218
+ ].includes(control.type);
219
+ case "password": return control.type === "password";
220
+ case "number": return control.type === "number" || control.type === "range";
221
+ case "datetime": return control.type === "datetime-local";
222
+ case "confirm": return control.type === "checkbox";
223
+ case "checkbox": return control.type === "checkbox" && controls.every((entry) => entry instanceof HTMLInputElement);
224
+ case "select": return control.type === "radio" && controls.every((entry) => entry instanceof HTMLInputElement && entry.type === "radio");
225
+ case "editor": return false;
226
+ default: return control.type === field.control;
227
+ }
228
+ });
229
+ }
230
+ /**
231
+ * Reads one compatible native population using the document's blank-to-absence semantics.
232
+ * @param field - The field declaring the answer shape.
233
+ * @param controls - Controls sharing the field's name, in native order.
234
+ * @returns The native answer, or undefined for an absent answer or empty population.
235
+ * @throws Thrown when the population or answer is incompatible, including numeric bad input.
236
+ * @example
237
+ * ```ts
238
+ * const input = document.createElement('input')
239
+ * input.value = 'Ada'
240
+ * readFormControls({ control: 'text', name: 'name' }, [input]) // 'Ada'
241
+ * ```
242
+ */
243
+ function readFormControls(field, controls) {
244
+ if (!matchesFormControls(field, controls)) throw new FormBindingError("CONTROL", `Incompatible controls for "${field.name}"`, { field: field.name });
245
+ const first = controls[0];
246
+ if (first === void 0) return void 0;
247
+ let value;
248
+ if (field.control === "checkbox") value = first instanceof HTMLSelectElement ? Array.from(first.selectedOptions, (option) => option.value) : controls.filter((control) => control instanceof HTMLInputElement && control.checked).map((control) => control.value);
249
+ else if (field.control === "select" && first instanceof HTMLInputElement) value = controls.find((control) => control instanceof HTMLInputElement && control.checked)?.value;
250
+ else if (field.control === "confirm" && first instanceof HTMLInputElement) value = first.checked;
251
+ else if (field.control === "number" && first instanceof HTMLInputElement) {
252
+ if (first.validity.badInput) throw new FormBindingError("CONTROL", "Enter a number", { field: field.name });
253
+ value = first.value === "" ? void 0 : first.valueAsNumber;
254
+ } else if (field.control === "file" && first instanceof HTMLInputElement) value = Array.from(first.files ?? [], (file) => file.name);
255
+ else value = first.value;
256
+ if (!matchesAnswer(value)) return void 0;
257
+ if (value !== void 0 && !matchesField(field, value)) throw new FormBindingError("CONTROL", `Incompatible native answer for "${field.name}"`, { field: field.name });
258
+ return value;
259
+ }
260
+ //#endregion
261
+ //#region src/browser/validators.ts
262
+ /**
263
+ * Checks whether a value is a native answer control.
264
+ * @param value - The value to inspect.
265
+ * @returns True if the value is an input, select, or textarea; false otherwise.
266
+ * @example
267
+ * ```ts
268
+ * isFormControl(document.createElement('input')) // true
269
+ * ```
270
+ */
271
+ function isFormControl(value) {
272
+ return typeof HTMLInputElement !== "undefined" && isInstance(value, HTMLInputElement) || typeof HTMLSelectElement !== "undefined" && isInstance(value, HTMLSelectElement) || typeof HTMLTextAreaElement !== "undefined" && isInstance(value, HTMLTextAreaElement);
273
+ }
274
+ //#endregion
275
+ //#region src/browser/FormBinding.ts
276
+ /** Implements a controlled native form over a borrowed document. */
277
+ var FormBinding = class {
278
+ #element;
279
+ #document;
280
+ #options;
281
+ #emitter;
282
+ #observer;
283
+ #listeners = new AbortController();
284
+ #attributes = /* @__PURE__ */ new Map();
285
+ #validity = /* @__PURE__ */ new Map();
286
+ #feedback = /* @__PURE__ */ new Map();
287
+ #excluded = /* @__PURE__ */ new Set();
288
+ #logical = /* @__PURE__ */ new Set();
289
+ #unreadable = /* @__PURE__ */ new Set();
290
+ #names = /* @__PURE__ */ new Map();
291
+ #refresh = this.#onState.bind(this);
292
+ #fill = this.#onFill.bind(this);
293
+ #disable = this.#onDisable.bind(this);
294
+ #enable = this.#onEnable.bind(this);
295
+ #clear = this.#onClear.bind(this);
296
+ #abandon = this.destroy.bind(this);
297
+ #controls = /* @__PURE__ */ new Map();
298
+ #summary;
299
+ #summaryText = "";
300
+ #destroyed = false;
301
+ #running = false;
302
+ #queued = false;
303
+ #reading = false;
304
+ #excluding;
305
+ #changed = false;
306
+ #notified = false;
307
+ #scheduled = false;
308
+ #submitting = false;
309
+ /**
310
+ * Binds existing controls to the supplied document's answers.
311
+ * @param element - The native form, including its externally associated controls.
312
+ * @param document - The borrowed answer document.
313
+ * @param options - Feedback destinations and listeners.
314
+ * @throws Thrown when controls contradict the schema or native initialization fails.
315
+ */
316
+ constructor(element, document, options) {
317
+ this.#element = element;
318
+ this.#document = document;
319
+ this.#options = options;
320
+ this.#emitter = new Emitter(options);
321
+ this.#observer = new MutationObserver(() => this.#onDocument());
322
+ for (const name of document.disabled) this.#logical.add(name);
323
+ try {
324
+ this.#controls = this.#discover();
325
+ const signal = this.#listeners.signal;
326
+ element.addEventListener("submit", (event) => this.#onSubmit(event), { signal });
327
+ element.addEventListener("reset", (event) => this.#onReset(event), { signal });
328
+ element.ownerDocument.addEventListener("input", (event) => this.#onInput(event), { signal });
329
+ element.ownerDocument.addEventListener("change", (event) => this.#onInput(event), { signal });
330
+ element.ownerDocument.addEventListener("focusout", (event) => this.#onBlur(event), { signal });
331
+ element.ownerDocument.addEventListener("click", (event) => this.#onClick(event), { signal });
332
+ document.emitter.on("fill", this.#fill);
333
+ document.emitter.on("disable", this.#disable);
334
+ document.emitter.on("enable", this.#enable);
335
+ document.emitter.on("clear", this.#clear);
336
+ document.emitter.on("abandon", this.#abandon);
337
+ for (const event of [
338
+ "touch",
339
+ "validate",
340
+ "prepare",
341
+ "accept",
342
+ "abort",
343
+ "submit"
344
+ ]) document.emitter.on(event, this.#refresh);
345
+ this.refresh();
346
+ if (!this.#destroyed) this.#observer.observe(element.ownerDocument, {
347
+ subtree: true,
348
+ childList: true,
349
+ attributes: true,
350
+ attributeFilter: [
351
+ "name",
352
+ "form",
353
+ "id",
354
+ "type",
355
+ "multiple",
356
+ "disabled",
357
+ "required",
358
+ "min",
359
+ "max",
360
+ "step",
361
+ "pattern",
362
+ "minlength",
363
+ "maxlength",
364
+ "value",
365
+ "hidden",
366
+ "inert",
367
+ "style",
368
+ "class"
369
+ ]
370
+ });
371
+ } catch (error) {
372
+ this.destroy();
373
+ throw error;
374
+ }
375
+ }
376
+ /** Holds the native form. */
377
+ get element() {
378
+ return this.#element;
379
+ }
380
+ /** Holds the borrowed answer document. */
381
+ get document() {
382
+ return this.#document;
383
+ }
384
+ /** Reports whether the binding released its resources. */
385
+ get destroyed() {
386
+ return this.#destroyed;
387
+ }
388
+ /** Holds the binding's isolated event emitter. */
389
+ get emitter() {
390
+ return this.#emitter;
391
+ }
392
+ /** Imports mounted enabled controls without acknowledging their answers. */
393
+ read() {
394
+ if (this.#destroyed || this.#reading || this.#document.status !== "editing") return;
395
+ const controls = this.#discover();
396
+ const answers = /* @__PURE__ */ new Map();
397
+ for (const field of this.#document.schema.fields) {
398
+ const group = controls.get(field.name);
399
+ if (group === void 0 || field.locked || this.#logical.has(field.name)) continue;
400
+ const enabled = group.filter((control) => !this.#nativeDisabled(control));
401
+ if (enabled.length === 0) continue;
402
+ if (enabled.some((control) => control.validity.badInput)) {
403
+ this.#unreadable.add(field.name);
404
+ continue;
405
+ }
406
+ this.#unreadable.delete(field.name);
407
+ answers.set(field.name, readFormControls(field, enabled));
408
+ }
409
+ this.#controls = controls;
410
+ this.#reading = true;
411
+ try {
412
+ this.#exclusions();
413
+ for (const [name, value] of answers) {
414
+ if (this.#destroyed) break;
415
+ const values = this.#document.values;
416
+ const previous = Object.hasOwn(values, name) ? values[name] : void 0;
417
+ if (previous === void 0 || value === void 0 ? previous !== value : !matchesValue(previous, value)) this.#document.fill(name, value);
418
+ }
419
+ } finally {
420
+ this.#reading = false;
421
+ if (!this.#destroyed) this.#drain();
422
+ }
423
+ }
424
+ /** Rediscovers controls and projects the document into the native form. */
425
+ refresh() {
426
+ if (this.#destroyed) return;
427
+ this.#unreadable.clear();
428
+ this.#drain();
429
+ }
430
+ /** Releases owned DOM effects and listeners without destroying the document. */
431
+ destroy() {
432
+ if (this.#destroyed) return;
433
+ this.#destroyed = true;
434
+ this.#observer.disconnect();
435
+ this.#listeners.abort();
436
+ this.#document.emitter.off("fill", this.#fill);
437
+ this.#document.emitter.off("disable", this.#disable);
438
+ this.#document.emitter.off("enable", this.#enable);
439
+ this.#document.emitter.off("clear", this.#clear);
440
+ this.#document.emitter.off("abandon", this.#abandon);
441
+ for (const event of [
442
+ "touch",
443
+ "validate",
444
+ "prepare",
445
+ "accept",
446
+ "abort",
447
+ "submit"
448
+ ]) this.#document.emitter.off(event, this.#refresh);
449
+ for (const element of this.#attributes.keys()) this.#restore(element);
450
+ for (const control of this.#validity.keys()) this.#restoreValidity(control);
451
+ for (const node of this.#feedback.values()) node.remove();
452
+ this.#summary?.remove();
453
+ if (this.#document.status === "editing") {
454
+ for (const name of this.#excluded) if (!this.#logical.has(name)) try {
455
+ this.#document.enable(name);
456
+ } catch (error) {
457
+ this.#emitter.emit("error", error);
458
+ }
459
+ }
460
+ this.#excluded.clear();
461
+ this.#feedback.clear();
462
+ this.#names.clear();
463
+ this.#unreadable.clear();
464
+ this.#logical.clear();
465
+ this.#controls = /* @__PURE__ */ new Map();
466
+ this.#summary = void 0;
467
+ this.#emitter.destroy();
468
+ }
469
+ #drain() {
470
+ if (this.#destroyed) return;
471
+ this.#queued = true;
472
+ if (this.#running || this.#reading) return;
473
+ this.#running = true;
474
+ try {
475
+ let turns = 0;
476
+ while (this.#queued && !this.#destroyed && turns < 2) {
477
+ this.#queued = false;
478
+ this.#changed = false;
479
+ this.#controls = this.#discover();
480
+ this.#releaseRemoved();
481
+ this.#exclusions();
482
+ if (this.#destroyed) break;
483
+ this.#paint();
484
+ if (!this.#destroyed && (this.#changed || !this.#notified)) {
485
+ this.#notified = true;
486
+ this.#emitter.emit("render");
487
+ }
488
+ turns += 1;
489
+ }
490
+ } catch (error) {
491
+ this.#queued = false;
492
+ throw isFormBindingError(error) ? error : new FormBindingError("DOM", "Native form projection failed", { cause: error });
493
+ } finally {
494
+ this.#running = false;
495
+ if (this.#queued && !this.#destroyed && !this.#scheduled) {
496
+ this.#scheduled = true;
497
+ queueMicrotask(() => {
498
+ this.#scheduled = false;
499
+ if (!this.#destroyed) this.#onDocument();
500
+ });
501
+ }
502
+ }
503
+ }
504
+ #discover() {
505
+ const groups = /* @__PURE__ */ new Map();
506
+ for (const control of Array.from(this.#element.elements)) {
507
+ if (!isFormControl(control) || control.form !== this.#element || !this.#document.field(control.name)) continue;
508
+ if (control instanceof HTMLInputElement && [
509
+ "submit",
510
+ "reset",
511
+ "button",
512
+ "image"
513
+ ].includes(control.type)) continue;
514
+ const group = groups.get(control.name) ?? [];
515
+ group.push(control);
516
+ groups.set(control.name, group);
517
+ }
518
+ for (const field of this.#document.schema.fields) {
519
+ const group = groups.get(field.name);
520
+ if (group !== void 0 && !matchesFormControls(field, group)) throw new FormBindingError("CONTROL", `Incompatible controls for "${field.name}"`, { field: field.name });
521
+ }
522
+ return groups;
523
+ }
524
+ #nativeDisabled(control) {
525
+ const owned = this.#attributes.get(control)?.get("disabled");
526
+ if (owned !== void 0 && control.getAttribute("disabled") === owned.value && owned.original === null) {
527
+ let ancestor = control.parentElement;
528
+ while (ancestor !== null) {
529
+ if (ancestor instanceof HTMLFieldSetElement && ancestor.disabled) {
530
+ const legend = Array.from(ancestor.children).find((child) => child instanceof HTMLLegendElement);
531
+ if (legend === void 0 || !legend.contains(control)) return true;
532
+ }
533
+ ancestor = ancestor.parentElement;
534
+ }
535
+ return false;
536
+ }
537
+ return control.matches(":disabled");
538
+ }
539
+ #exclusions() {
540
+ if (this.#document.status !== "editing") return;
541
+ for (const field of this.#document.schema.fields) {
542
+ if (this.#destroyed) return;
543
+ const group = this.#controls.get(field.name);
544
+ const excluded = group !== void 0 && group.every((control) => this.#nativeDisabled(control));
545
+ if (excluded && !this.#logical.has(field.name) && !this.#excluded.has(field.name)) {
546
+ this.#excluded.add(field.name);
547
+ this.#excluding = field.name;
548
+ try {
549
+ this.#document.disable(field.name);
550
+ } finally {
551
+ this.#excluding = void 0;
552
+ }
553
+ } else if (!excluded && this.#excluded.delete(field.name) && !this.#logical.has(field.name)) {
554
+ this.#excluding = field.name;
555
+ try {
556
+ this.#document.enable(field.name);
557
+ } finally {
558
+ this.#excluding = void 0;
559
+ }
560
+ }
561
+ }
562
+ }
563
+ #paint() {
564
+ this.#attribute(this.#element, "novalidate", "");
565
+ this.#attribute(this.#element, "aria-busy", this.#document.pending === void 0 ? "false" : "true");
566
+ const errors = [...this.#document.errors];
567
+ const values = this.#document.values;
568
+ for (const field of this.#document.schema.fields) {
569
+ if (this.#destroyed) return;
570
+ const group = this.#controls.get(field.name) ?? [];
571
+ for (const control of group) {
572
+ this.#metadata(field, control);
573
+ if (!this.#unreadable.has(field.name)) this.#value(field, control, Object.hasOwn(values, field.name) ? values[field.name] : void 0);
574
+ this.#restoreValidity(control);
575
+ if (!this.#document.disabled.has(field.name) && !control.validity.valid && control.willValidate) errors.push({
576
+ field: field.name,
577
+ message: control.validationMessage
578
+ });
579
+ }
580
+ }
581
+ for (const field of this.#document.schema.fields) {
582
+ if (this.#destroyed) return;
583
+ const message = [...new Set(errors.filter((error) => error.field === field.name).map((error) => error.message))].join("\n");
584
+ const visible = this.#document.touched.has(field.name) && !this.#document.disabled.has(field.name);
585
+ const group = this.#controls.get(field.name) ?? [];
586
+ let feedback = this.#feedback.get(field.name);
587
+ if (feedback === void 0 && (group.length > 0 || this.#options?.feedback?.has(field.name))) {
588
+ feedback = this.#element.ownerDocument.createElement("span");
589
+ feedback.id = `form-feedback-${crypto.randomUUID()}`;
590
+ feedback.dataset.formFeedback = field.name;
591
+ const destination = this.#options?.feedback?.get(field.name);
592
+ if (destination !== void 0) destination.append(feedback);
593
+ else (group.at(-1) ?? this.#element).insertAdjacentElement("afterend", feedback);
594
+ this.#feedback.set(field.name, feedback);
595
+ }
596
+ if (feedback !== void 0) {
597
+ if (feedback.textContent !== message) {
598
+ feedback.textContent = message;
599
+ this.#changed = true;
600
+ }
601
+ if (feedback.hidden !== (!visible || message === "")) {
602
+ feedback.hidden = !visible || message === "";
603
+ this.#changed = true;
604
+ }
605
+ }
606
+ for (const control of group) {
607
+ if (message !== "" && !control.validity.customError) {
608
+ this.#validity.set(control, {
609
+ original: "",
610
+ value: message
611
+ });
612
+ control.setCustomValidity(message);
613
+ }
614
+ this.#attribute(control, "aria-invalid", visible && message !== "" ? "true" : null);
615
+ if (feedback !== void 0) this.#description(control, feedback.id, visible && message !== "");
616
+ }
617
+ }
618
+ this.#paintSummary(errors.filter((error) => this.#document.touched.has(error.field) && !this.#document.disabled.has(error.field)));
619
+ for (const control of Array.from(this.#element.elements)) if (control instanceof HTMLButtonElement && control.type === "submit" || control instanceof HTMLInputElement && ["submit", "image"].includes(control.type)) this.#attribute(control, "aria-busy", this.#document.pending === void 0 ? "false" : "true");
620
+ }
621
+ #metadata(field, control) {
622
+ if (this.#logical.has(field.name)) this.#attribute(control, "disabled", "");
623
+ else this.#releaseAttribute(control, "disabled");
624
+ if (field.locked && !(control instanceof HTMLSelectElement) && !(control instanceof HTMLInputElement && [
625
+ "radio",
626
+ "checkbox",
627
+ "file",
628
+ "range",
629
+ "color"
630
+ ].includes(control.type))) this.#attribute(control, "readonly", "");
631
+ if (field.rule?.required && ![
632
+ "confirm",
633
+ "checkbox",
634
+ "file"
635
+ ].includes(field.control)) this.#attribute(control, "required", "");
636
+ if (field.control === "number" || field.control === "date" || field.control === "time" || field.control === "datetime") {
637
+ if (field.rule?.minimum !== void 0 && !control.hasAttribute("min")) this.#attribute(control, "min", String(field.rule.minimum));
638
+ if (field.rule?.maximum !== void 0 && !control.hasAttribute("max")) this.#attribute(control, "max", String(field.rule.maximum));
639
+ if (field.control === "number" && field.rule?.step !== void 0 && !control.hasAttribute("step")) this.#attribute(control, "step", String(field.rule.step));
640
+ }
641
+ }
642
+ #value(field, control, value) {
643
+ if (control instanceof HTMLInputElement && control.type === "file") {
644
+ const names = Array.from(control.files ?? [], (file) => file.name);
645
+ if (names.length > 0 && (value === void 0 || !matchesValue(names, value))) {
646
+ control.value = "";
647
+ this.#changed = true;
648
+ }
649
+ return;
650
+ }
651
+ if (control instanceof HTMLInputElement && (control.type === "checkbox" || control.type === "radio")) {
652
+ const checked = field.control === "confirm" ? value === true : Array.isArray(value) ? value.includes(control.value) : value === control.value;
653
+ if (control.checked !== checked) {
654
+ control.checked = checked;
655
+ this.#changed = true;
656
+ }
657
+ } else if (control instanceof HTMLSelectElement && control.multiple) for (const option of Array.from(control.options)) {
658
+ const selected = Array.isArray(value) && value.includes(option.value);
659
+ if (option.selected !== selected) {
660
+ option.selected = selected;
661
+ this.#changed = true;
662
+ }
663
+ }
664
+ else {
665
+ const text = value === void 0 ? "" : String(value);
666
+ if (control.value !== text || control instanceof HTMLInputElement && control.validity.badInput) {
667
+ const before = control.value;
668
+ const invalid = control.validity.badInput;
669
+ control.value = text;
670
+ if (control.value !== before || invalid) this.#changed = true;
671
+ }
672
+ }
673
+ }
674
+ #attribute(element, name, value) {
675
+ const attributes = this.#attributes.get(element) ?? /* @__PURE__ */ new Map();
676
+ const previous = attributes.get(name);
677
+ const current = element.getAttribute(name);
678
+ const original = previous === void 0 || previous.value !== current ? current : previous.original;
679
+ if (current !== value) {
680
+ if (value === null) element.removeAttribute(name);
681
+ else element.setAttribute(name, value);
682
+ this.#changed = true;
683
+ }
684
+ attributes.set(name, {
685
+ original,
686
+ value
687
+ });
688
+ this.#attributes.set(element, attributes);
689
+ }
690
+ #description(control, id, shown) {
691
+ const tokens = (control.getAttribute("aria-describedby") ?? "").split(/\s+/).filter((token) => token !== "" && token !== id);
692
+ if (shown) tokens.push(id);
693
+ this.#attribute(control, "aria-describedby", tokens.length === 0 ? null : tokens.join(" "));
694
+ }
695
+ #releaseAttribute(element, name) {
696
+ const attributes = this.#attributes.get(element);
697
+ const owned = attributes?.get(name);
698
+ if (owned === void 0) return;
699
+ if (element.getAttribute(name) === owned.value) {
700
+ if (owned.original === null) element.removeAttribute(name);
701
+ else element.setAttribute(name, owned.original);
702
+ this.#changed = true;
703
+ }
704
+ attributes?.delete(name);
705
+ }
706
+ #restore(element) {
707
+ for (const name of this.#attributes.get(element)?.keys() ?? []) this.#releaseAttribute(element, name);
708
+ const ids = new Set([...this.#feedback.values()].map((feedback) => feedback.id));
709
+ const description = element.getAttribute("aria-describedby");
710
+ if (description !== null) {
711
+ const tokens = description.split(/\s+/).filter((token) => token !== "" && !ids.has(token));
712
+ if (tokens.length === 0) element.removeAttribute("aria-describedby");
713
+ else if (tokens.join(" ") !== description) element.setAttribute("aria-describedby", tokens.join(" "));
714
+ }
715
+ this.#attributes.delete(element);
716
+ }
717
+ #restoreValidity(control) {
718
+ const owned = this.#validity.get(control);
719
+ if (owned !== void 0 && control.validity.customError && control.validationMessage === owned.value) control.setCustomValidity(owned.original);
720
+ this.#validity.delete(control);
721
+ }
722
+ #releaseRemoved() {
723
+ const live = new Set([...this.#controls.values()].flat());
724
+ for (const [control, name] of this.#names) if (!live.has(control) || control.name !== name) {
725
+ this.#restoreValidity(control);
726
+ this.#restore(control);
727
+ this.#names.delete(control);
728
+ }
729
+ for (const control of live) this.#names.set(control, control.name);
730
+ for (const element of this.#attributes.keys()) if ((isFormControl(element) || element instanceof HTMLButtonElement) && element.form !== this.#element) this.#restore(element);
731
+ for (const [name, feedback] of this.#feedback) if (!this.#controls.has(name) && !this.#options?.feedback?.has(name)) {
732
+ feedback.remove();
733
+ this.#feedback.delete(name);
734
+ }
735
+ }
736
+ #paintSummary(errors) {
737
+ const destination = this.#options?.summary;
738
+ if (destination === void 0) return;
739
+ const text = JSON.stringify(errors.map((error) => [
740
+ error.field,
741
+ error.message,
742
+ this.#focusable(error.field)?.id
743
+ ]));
744
+ if (text === this.#summaryText) return;
745
+ this.#summaryText = text;
746
+ this.#changed = true;
747
+ this.#summary?.remove();
748
+ this.#summary = this.#element.ownerDocument.createElement("ul");
749
+ this.#summary.dataset.formSummary = "";
750
+ this.#summary.hidden = errors.length === 0;
751
+ for (const error of errors) {
752
+ const entry = this.#element.ownerDocument.createElement("li");
753
+ const target = this.#focusable(error.field);
754
+ const label = this.#document.field(error.field)?.label ?? error.field;
755
+ if (target === void 0) entry.textContent = `${label}: ${error.message}`;
756
+ else {
757
+ if (target.id === "") this.#attribute(target, "id", `form-control-${crypto.randomUUID()}`);
758
+ const link = this.#element.ownerDocument.createElement("a");
759
+ link.href = `#${target.id}`;
760
+ link.textContent = `${label}: ${error.message}`;
761
+ link.dataset.formField = error.field;
762
+ entry.append(link);
763
+ }
764
+ this.#summary.append(entry);
765
+ }
766
+ destination.append(this.#summary);
767
+ }
768
+ #focusable(name) {
769
+ return this.#controls.get(name)?.find((control) => !control.matches(":disabled") && control.closest("[inert]") === null && control.getClientRects().length > 0 && !["hidden", "collapse"].includes(getComputedStyle(control).visibility) && !(control instanceof HTMLInputElement && control.type === "hidden"));
770
+ }
771
+ #onDocument() {
772
+ if (this.#destroyed) return;
773
+ try {
774
+ this.#drain();
775
+ } catch (error) {
776
+ this.#emitter.emit("error", error);
777
+ }
778
+ }
779
+ #onFill(name) {
780
+ this.#unreadable.delete(name);
781
+ this.#onState();
782
+ }
783
+ #onState() {
784
+ this.#notified = false;
785
+ this.#onDocument();
786
+ }
787
+ #onDisable(name) {
788
+ if (this.#excluding !== name) this.#logical.add(name);
789
+ this.#onState();
790
+ }
791
+ #onEnable(name) {
792
+ if (this.#excluding !== name) {
793
+ this.#logical.delete(name);
794
+ this.#excluded.delete(name);
795
+ }
796
+ this.#onState();
797
+ }
798
+ #onClear() {
799
+ this.#logical.clear();
800
+ this.#excluded.clear();
801
+ this.#unreadable.clear();
802
+ for (const name of this.#document.disabled) this.#logical.add(name);
803
+ this.#onState();
804
+ }
805
+ #onInput(event) {
806
+ const control = event.target;
807
+ if (!isFormControl(control) || control.form !== this.#element || !this.#document.field(control.name) || this.#destroyed) return;
808
+ try {
809
+ this.read();
810
+ } catch (error) {
811
+ this.#emitter.emit("error", error);
812
+ }
813
+ }
814
+ #onClick(event) {
815
+ const control = event.target;
816
+ if (control instanceof Element) {
817
+ const link = control.closest("a[data-form-field]");
818
+ if (link instanceof HTMLAnchorElement && this.#summary?.contains(link) && link.dataset.formField !== void 0) {
819
+ event.preventDefault();
820
+ this.#focusable(link.dataset.formField)?.focus();
821
+ }
822
+ }
823
+ if (control instanceof HTMLInputElement && control.form === this.#element && this.#document.field(control.name)?.locked && [
824
+ "file",
825
+ "checkbox",
826
+ "radio",
827
+ "color"
828
+ ].includes(control.type)) event.preventDefault();
829
+ }
830
+ #onBlur(event) {
831
+ const control = event.target;
832
+ if (!isFormControl(control) || control.form !== this.#element || !this.#document.field(control.name) || this.#destroyed || this.#document.status !== "editing") return;
833
+ try {
834
+ this.#document.touch(control.name);
835
+ this.#drain();
836
+ } catch (error) {
837
+ this.#emitter.emit("error", error);
838
+ }
839
+ }
840
+ #onSubmit(event) {
841
+ if (event.defaultPrevented || this.#destroyed) return;
842
+ event.preventDefault();
843
+ if (this.#submitting || this.#document.pending !== void 0) {
844
+ this.#emitter.emit("error", new FormBindingError("PENDING", "A submission is already pending"));
845
+ return;
846
+ }
847
+ this.#submitting = true;
848
+ try {
849
+ this.read();
850
+ if (this.#destroyed) return;
851
+ for (const field of this.#document.schema.fields) {
852
+ if (!this.#document.disabled.has(field.name)) this.#document.touch(field.name);
853
+ if (this.#destroyed) return;
854
+ }
855
+ this.#drain();
856
+ const native = [...this.#controls].filter(([name, controls]) => !this.#document.disabled.has(name) && controls.some((control) => control.willValidate && !control.validity.valid));
857
+ if (native.length > 0) {
858
+ for (const [name] of native) {
859
+ const target = this.#focusable(name);
860
+ if (target !== void 0) {
861
+ target.focus();
862
+ break;
863
+ }
864
+ }
865
+ return;
866
+ }
867
+ const result = this.#document.prepare();
868
+ if (this.#destroyed) return;
869
+ this.#drain();
870
+ if (result.success) {
871
+ if (!result.value.signal.aborted && this.#document.pending === result.value) this.#emitter.emit("submit", result.value, event.submitter ?? void 0);
872
+ } else for (const error of result.error) {
873
+ const target = this.#focusable(error.field);
874
+ if (target !== void 0) {
875
+ target.focus();
876
+ break;
877
+ }
878
+ }
879
+ } catch (error) {
880
+ this.#emitter.emit("error", error);
881
+ } finally {
882
+ this.#submitting = false;
883
+ }
884
+ }
885
+ #onReset(event) {
886
+ if (event.defaultPrevented || this.#destroyed) return;
887
+ event.preventDefault();
888
+ try {
889
+ this.#document.clear();
890
+ this.refresh();
891
+ } catch (error) {
892
+ this.#emitter.emit("error", error);
893
+ }
894
+ }
895
+ };
896
+ //#endregion
897
+ //#region src/browser/factories.ts
898
+ /**
899
+ * Creates a native form binding exposed through its lifecycle interface.
900
+ * @param element - The native form.
901
+ * @param document - The borrowed answer document.
902
+ * @param options - Feedback destinations and listeners.
903
+ * @returns The initialized native binding.
904
+ * @example
905
+ * ```ts
906
+ * const binding = createFormBinding(element, document)
907
+ * binding.refresh()
908
+ * ```
909
+ */
910
+ function createFormBinding(element, document, options) {
911
+ return new FormBinding(element, document, options);
912
+ }
913
+ //#endregion
914
+ export { FormBinding, FormBindingError, createFormBinding, isFormBindingError, isFormControl, matchesFormControls, readFormControls };
915
+
916
+ //# sourceMappingURL=index.js.map