@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.
@@ -1,4 +1,5 @@
1
- import { attempt, isArray, isBoolean, isFiniteNumber, isInstance, isString, readArrayEntries } from "@orkestrel/contract";
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 === "checkbox") value = first instanceof HTMLSelectElement ? Array.from(first.selectedOptions, (option) => option.value) : controls.filter((control) => control instanceof HTMLInputElement && control.checked).map((control) => control.value);
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
- this.#document.clear();
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
  };