@orkestrel/form 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/src/browser/index.d.ts +6 -572
- package/dist/src/browser/index.js +84 -182
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +63 -15
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +30 -9
- package/dist/src/core/index.d.ts +30 -9
- package/dist/src/core/index.js +63 -15
- package/dist/src/core/index.js.map +1 -1
- package/package.json +10 -10
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isInstance } from "@orkestrel/contract";
|
|
2
|
+
import { formatMessage, matchesAnswer, matchesField, matchesValue } from "../core/index.js";
|
|
2
3
|
import { Emitter } from "@orkestrel/emitter";
|
|
3
4
|
//#region src/browser/errors.ts
|
|
4
5
|
/** Reports an incompatible control, overlapping submission, or native DOM failure. */
|
|
@@ -30,182 +31,6 @@ var FormBindingError = class extends Error {
|
|
|
30
31
|
function isFormBindingError(value) {
|
|
31
32
|
return isInstance(value, FormBindingError);
|
|
32
33
|
}
|
|
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
|
-
/** Holds the default failure copy for every named field rule. */
|
|
105
|
-
var RULE_MESSAGES = Object.freeze({
|
|
106
|
-
required: "This field is required",
|
|
107
|
-
minimum: "Must be at least {limit}",
|
|
108
|
-
maximum: "Must be at most {limit}",
|
|
109
|
-
step: "Must be a multiple of {limit}",
|
|
110
|
-
pattern: "Must match the required format",
|
|
111
|
-
email: "Must be a valid email address",
|
|
112
|
-
url: "Must be a valid URL",
|
|
113
|
-
integer: "Must be an integer",
|
|
114
|
-
alphanumeric: "Must contain only letters and numbers"
|
|
115
|
-
});
|
|
116
|
-
Object.freeze(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
|
|
117
|
-
Object.freeze(/^https?:\/\/[^\s]+$/);
|
|
118
|
-
Object.freeze(/^[A-Za-z0-9]+$/);
|
|
119
|
-
Object.freeze(/^[+-]?\d+$/);
|
|
120
|
-
/** Matches a six-digit hexadecimal color string. */
|
|
121
|
-
var COLOR_PATTERN = Object.freeze(/^#[0-9A-Fa-f]{6}$/);
|
|
122
|
-
/** Matches an ISO calendar date string in `YYYY-MM-DD` form. */
|
|
123
|
-
var DATE_PATTERN = Object.freeze(/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/);
|
|
124
|
-
/** Matches a 24-hour time string with optional seconds. */
|
|
125
|
-
var TIME_PATTERN = Object.freeze(/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/);
|
|
126
|
-
/** Matches an ISO local date and time string with optional seconds. */
|
|
127
|
-
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)?$/);
|
|
128
|
-
//#endregion
|
|
129
|
-
//#region src/core/helpers.ts
|
|
130
|
-
/**
|
|
131
|
-
* Checks whether a value has the shape required by one field control.
|
|
132
|
-
*
|
|
133
|
-
* @remarks
|
|
134
|
-
* Every write and every seeded value passes through this gate, and it reads `STRING_LIMIT` and
|
|
135
|
-
* `LIST_LIMIT` before it consults the control, so no regular expression sees an over-long value.
|
|
136
|
-
*
|
|
137
|
-
* @param field - The field that owns the value.
|
|
138
|
-
* @param value - The unknown value to inspect.
|
|
139
|
-
* @returns True if the control can hold the value; false otherwise.
|
|
140
|
-
*/
|
|
141
|
-
function matchesField(field, value) {
|
|
142
|
-
if (isString(value) && value.length > 65536) return false;
|
|
143
|
-
let entries;
|
|
144
|
-
if (isArray(value)) {
|
|
145
|
-
const length = attempt(() => value.length);
|
|
146
|
-
if (!length.success || length.value > 1024) return false;
|
|
147
|
-
const read = readArrayEntries(value);
|
|
148
|
-
if (!read.success || !read.value.dense) return false;
|
|
149
|
-
entries = read.value.entries;
|
|
150
|
-
if (entries.some((entry) => !isString(entry) || entry.length > 65536)) return false;
|
|
151
|
-
}
|
|
152
|
-
switch (field.control) {
|
|
153
|
-
case "text":
|
|
154
|
-
case "editor":
|
|
155
|
-
case "password": return isString(value);
|
|
156
|
-
case "number": return isFiniteNumber(value);
|
|
157
|
-
case "date": return isString(value) && DATE_PATTERN.test(value);
|
|
158
|
-
case "time": return isString(value) && TIME_PATTERN.test(value);
|
|
159
|
-
case "datetime": return isString(value) && DATETIME_PATTERN.test(value);
|
|
160
|
-
case "color": return isString(value) && COLOR_PATTERN.test(value);
|
|
161
|
-
case "confirm": return isBoolean(value);
|
|
162
|
-
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));
|
|
163
|
-
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;
|
|
164
|
-
case "file": return entries !== void 0 && (field.multiple === true || entries.length <= 1);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* Decides whether a raw binding value projects to an answered field.
|
|
169
|
-
*
|
|
170
|
-
* @remarks
|
|
171
|
-
* Bind with `fill(name, matchesAnswer(raw) ? raw : undefined)`. This projection treats an absent
|
|
172
|
-
* value and a string containing only whitespace as unanswered. Every other field value is an
|
|
173
|
-
* answer, including an empty list, `false`, and zero. Core evaluation does not use this projection:
|
|
174
|
-
* its `required` rule remains presence-only.
|
|
175
|
-
*
|
|
176
|
-
* @param value - The raw field value, or absence.
|
|
177
|
-
* @returns True if the binding preserves the value as an answer; false otherwise.
|
|
178
|
-
*/
|
|
179
|
-
function matchesAnswer(value) {
|
|
180
|
-
return value !== void 0 && (!isString(value) || value.trim().length > 0);
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* Compares two field values by scalar identity or ordered list content.
|
|
184
|
-
*
|
|
185
|
-
* @param a - The first field value.
|
|
186
|
-
* @param b - The second field value.
|
|
187
|
-
* @returns True if both values contain the same answer; false otherwise.
|
|
188
|
-
*/
|
|
189
|
-
function matchesValue(a, b) {
|
|
190
|
-
if (isArray(a) || isArray(b)) return isArray(a) && isArray(b) && a.length === b.length && a.every((entry, index) => entry === b[index]);
|
|
191
|
-
return a === b;
|
|
192
|
-
}
|
|
193
|
-
/**
|
|
194
|
-
* Resolves and interpolates one rule message.
|
|
195
|
-
*
|
|
196
|
-
* @remarks
|
|
197
|
-
* A replacement in `messages` is read first and {@link RULE_MESSAGES} supplies the copy otherwise,
|
|
198
|
-
* and `{limit}` in whichever text wins is replaced with the rule's operand.
|
|
199
|
-
*
|
|
200
|
-
* @param rule - The rule whose message to resolve.
|
|
201
|
-
* @param limit - The optional operand substituted for `{limit}`.
|
|
202
|
-
* @param messages - Optional rule-specific message replacements.
|
|
203
|
-
* @returns The resolved failure text.
|
|
204
|
-
*/
|
|
205
|
-
function formatMessage(rule, limit, messages) {
|
|
206
|
-
const message = messages?.[rule] ?? RULE_MESSAGES[rule];
|
|
207
|
-
return limit === void 0 ? message : message.replaceAll("{limit}", String(limit));
|
|
208
|
-
}
|
|
209
34
|
//#endregion
|
|
210
35
|
//#region src/browser/helpers.ts
|
|
211
36
|
/**
|
|
@@ -223,7 +48,7 @@ function matchesFormControls(field, controls) {
|
|
|
223
48
|
if (controls.length > 1 && field.control !== "checkbox" && field.control !== "select") return false;
|
|
224
49
|
return controls.every((control) => {
|
|
225
50
|
if (control instanceof HTMLTextAreaElement) return field.control === "editor" && controls.length === 1;
|
|
226
|
-
if (control instanceof HTMLSelectElement) return controls.length === 1 && (control.multiple ? field.control === "checkbox" : field.control === "select");
|
|
51
|
+
if (control instanceof HTMLSelectElement) return controls.length === 1 && (control.multiple ? field.control === "checkbox" || field.control === "list" : field.control === "select");
|
|
227
52
|
switch (field.control) {
|
|
228
53
|
case "text": return [
|
|
229
54
|
"text",
|
|
@@ -300,7 +125,8 @@ function readFormControls(field, controls) {
|
|
|
300
125
|
const first = controls[0];
|
|
301
126
|
if (first === void 0) return void 0;
|
|
302
127
|
let value;
|
|
303
|
-
if (field.control === "
|
|
128
|
+
if (field.control === "list" && first instanceof HTMLSelectElement) value = Array.from(first.selectedOptions).filter((option) => !option.matches(":disabled")).map((option) => option.value);
|
|
129
|
+
else 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);
|
|
304
130
|
else if (field.control === "select" && first instanceof HTMLInputElement) value = controls.find((control) => control instanceof HTMLInputElement && control.checked)?.value;
|
|
305
131
|
else if (field.control === "confirm" && first instanceof HTMLInputElement) value = first.checked;
|
|
306
132
|
else if (field.control === "number" && first instanceof HTMLInputElement) {
|
|
@@ -344,6 +170,8 @@ var FormBinding = class {
|
|
|
344
170
|
#unreadable = /* @__PURE__ */ new Set();
|
|
345
171
|
#names = /* @__PURE__ */ new Map();
|
|
346
172
|
#readings = /* @__PURE__ */ new Map();
|
|
173
|
+
#lists = /* @__PURE__ */ new Map();
|
|
174
|
+
#resets = /* @__PURE__ */ new Map();
|
|
347
175
|
#deferred = /* @__PURE__ */ new Set();
|
|
348
176
|
#refresh = this.#onState.bind(this);
|
|
349
177
|
#fill = this.#onFill.bind(this);
|
|
@@ -536,6 +364,8 @@ var FormBinding = class {
|
|
|
536
364
|
this.#feedback.clear();
|
|
537
365
|
this.#names.clear();
|
|
538
366
|
this.#readings.clear();
|
|
367
|
+
this.#lists.clear();
|
|
368
|
+
this.#resets.clear();
|
|
539
369
|
this.#unreadable.clear();
|
|
540
370
|
this.#logical.clear();
|
|
541
371
|
this.#controls = /* @__PURE__ */ new Map();
|
|
@@ -691,12 +521,24 @@ var FormBinding = class {
|
|
|
691
521
|
}
|
|
692
522
|
this.#attribute(control, "aria-invalid", visible && message !== "" ? "true" : null);
|
|
693
523
|
if (feedback !== void 0) this.#description(control, feedback.id, visible && message !== "");
|
|
524
|
+
const presentation = this.#options?.presentations?.get(control);
|
|
525
|
+
if (presentation !== void 0) {
|
|
526
|
+
this.#attribute(presentation, "aria-invalid", visible && message !== "" ? "true" : null);
|
|
527
|
+
if (feedback !== void 0) this.#description(presentation, feedback.id, visible && message !== "");
|
|
528
|
+
}
|
|
694
529
|
}
|
|
695
530
|
}
|
|
696
531
|
this.#paintSummary(errors.filter((error) => this.#document.touched.has(error.field) && !this.#document.disabled.has(error.field)));
|
|
697
532
|
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");
|
|
698
533
|
}
|
|
699
534
|
#metadata(field, control) {
|
|
535
|
+
if (field.control === "list" && control instanceof HTMLSelectElement && !this.#lists.has(control)) {
|
|
536
|
+
const snapshot = control.cloneNode(true);
|
|
537
|
+
if (snapshot instanceof HTMLSelectElement) {
|
|
538
|
+
for (const [index, option] of Array.from(snapshot.options).entries()) option.selected = control.options[index]?.selected ?? false;
|
|
539
|
+
this.#lists.set(control, snapshot);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
700
542
|
if (this.#logical.has(field.name)) this.#attribute(control, "disabled", "");
|
|
701
543
|
else this.#releaseAttribute(control, "disabled");
|
|
702
544
|
if (field.locked && !(control instanceof HTMLSelectElement) && !(control instanceof HTMLInputElement && [
|
|
@@ -742,6 +584,32 @@ var FormBinding = class {
|
|
|
742
584
|
control.checked = checked;
|
|
743
585
|
changed = true;
|
|
744
586
|
}
|
|
587
|
+
} else if (control instanceof HTMLSelectElement && field.control === "list") {
|
|
588
|
+
const remaining = Array.from(control.options).filter((option) => !option.matches(":disabled"));
|
|
589
|
+
const selected = [];
|
|
590
|
+
for (const entry of Array.isArray(value) ? value : []) {
|
|
591
|
+
const index = remaining.findIndex((option) => option.value === entry);
|
|
592
|
+
let option = index < 0 ? void 0 : remaining.splice(index, 1)[0];
|
|
593
|
+
if (option === void 0) {
|
|
594
|
+
option = control.ownerDocument.createElement("option");
|
|
595
|
+
option.value = entry;
|
|
596
|
+
option.text = entry;
|
|
597
|
+
control.append(option);
|
|
598
|
+
changed = true;
|
|
599
|
+
}
|
|
600
|
+
if (!option.selected) changed = true;
|
|
601
|
+
option.selected = true;
|
|
602
|
+
selected.push(option);
|
|
603
|
+
}
|
|
604
|
+
for (const option of remaining) {
|
|
605
|
+
if (option.selected) changed = true;
|
|
606
|
+
option.selected = false;
|
|
607
|
+
}
|
|
608
|
+
const ordered = Array.from(control.selectedOptions).filter((option) => !option.matches(":disabled"));
|
|
609
|
+
if (selected.some((option, index) => ordered[index] !== option)) {
|
|
610
|
+
control.append(...selected);
|
|
611
|
+
changed = true;
|
|
612
|
+
}
|
|
745
613
|
} else if (control instanceof HTMLSelectElement && control.multiple) for (const option of Array.from(control.options)) {
|
|
746
614
|
const selected = Array.isArray(value) && value.includes(option.value);
|
|
747
615
|
if (option.selected !== selected) {
|
|
@@ -759,6 +627,13 @@ var FormBinding = class {
|
|
|
759
627
|
}
|
|
760
628
|
}
|
|
761
629
|
this.#readings.set(control, control.value);
|
|
630
|
+
if (control instanceof HTMLSelectElement) {
|
|
631
|
+
const before = this.#resets.get(control);
|
|
632
|
+
if (before !== void 0) {
|
|
633
|
+
this.#resets.delete(control);
|
|
634
|
+
changed = !matchesValue(before, Array.from(control.selectedOptions, (option) => option.value));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
762
637
|
if (changed) {
|
|
763
638
|
this.#changed = true;
|
|
764
639
|
this.#emitter.emit("write", control);
|
|
@@ -814,14 +689,21 @@ var FormBinding = class {
|
|
|
814
689
|
}
|
|
815
690
|
#releaseRemoved() {
|
|
816
691
|
const live = new Set([...this.#controls.values()].flat());
|
|
692
|
+
const presentations = new Set([...live].map((control) => this.#options?.presentations?.get(control)));
|
|
817
693
|
for (const [control, name] of this.#names) if (!live.has(control) || control.name !== name) {
|
|
694
|
+
if (control instanceof HTMLSelectElement) {
|
|
695
|
+
this.#lists.delete(control);
|
|
696
|
+
this.#resets.delete(control);
|
|
697
|
+
}
|
|
698
|
+
const presentation = this.#options?.presentations?.get(control);
|
|
699
|
+
if (presentation !== void 0) this.#restore(presentation);
|
|
818
700
|
this.#restoreValidity(control);
|
|
819
701
|
this.#restore(control);
|
|
820
702
|
this.#names.delete(control);
|
|
821
703
|
this.#readings.delete(control);
|
|
822
704
|
}
|
|
823
705
|
for (const control of live) this.#names.set(control, control.name);
|
|
824
|
-
for (const element of this.#attributes.keys()) if ((isFormControl(element) || element instanceof HTMLButtonElement) && element.form !== this.#element) this.#restore(element);
|
|
706
|
+
for (const element of this.#attributes.keys()) if (!presentations.has(element) && (isFormControl(element) || element instanceof HTMLButtonElement) && element.form !== this.#element) this.#restore(element);
|
|
825
707
|
for (const [name, feedback] of this.#feedback) if (!this.#controls.has(name) && !this.#options?.feedback?.has(name)) {
|
|
826
708
|
feedback.remove();
|
|
827
709
|
this.#feedback.delete(name);
|
|
@@ -860,7 +742,7 @@ var FormBinding = class {
|
|
|
860
742
|
destination.append(this.#summary);
|
|
861
743
|
}
|
|
862
744
|
#focusable(name) {
|
|
863
|
-
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"));
|
|
745
|
+
return this.#controls.get(name)?.filter((control) => !control.matches(":disabled")).map((control) => this.#options?.presentations?.get(control) ?? control)?.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"));
|
|
864
746
|
}
|
|
865
747
|
#onDocument(preserve = false) {
|
|
866
748
|
if (this.#destroyed) return;
|
|
@@ -1026,10 +908,30 @@ var FormBinding = class {
|
|
|
1026
908
|
if (event.defaultPrevented || this.#destroyed) return;
|
|
1027
909
|
event.preventDefault();
|
|
1028
910
|
try {
|
|
1029
|
-
|
|
911
|
+
const lists = /* @__PURE__ */ new Map();
|
|
912
|
+
for (const [control, snapshot] of this.#lists) if (control.form === this.#element && control.name === snapshot.name && this.#document.field(control.name)?.control === "list") {
|
|
913
|
+
lists.set(control, snapshot);
|
|
914
|
+
this.#resets.set(control, Array.from(control.selectedOptions, (option) => option.value));
|
|
915
|
+
}
|
|
916
|
+
this.#reading = true;
|
|
917
|
+
try {
|
|
918
|
+
this.#document.clear();
|
|
919
|
+
for (const [control, snapshot] of lists) {
|
|
920
|
+
if (this.#destroyed) return;
|
|
921
|
+
if (control.form !== this.#element || control.name !== snapshot.name) continue;
|
|
922
|
+
control.replaceChildren(...Array.from(snapshot.childNodes, (node) => node.cloneNode(true)));
|
|
923
|
+
for (const [index, option] of Array.from(control.options).entries()) option.selected = snapshot.options[index]?.selected ?? false;
|
|
924
|
+
const field = this.#document.field(control.name);
|
|
925
|
+
if (field !== void 0) this.#document.fill(field.name, readFormControls(field, [control]));
|
|
926
|
+
}
|
|
927
|
+
} finally {
|
|
928
|
+
this.#reading = false;
|
|
929
|
+
}
|
|
1030
930
|
this.refresh();
|
|
1031
931
|
} catch (error) {
|
|
1032
932
|
this.#emitter.emit("error", error);
|
|
933
|
+
} finally {
|
|
934
|
+
this.#resets.clear();
|
|
1033
935
|
}
|
|
1034
936
|
}
|
|
1035
937
|
};
|