@orkestrel/form 0.0.11 → 0.0.12

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 CHANGED
@@ -72,6 +72,11 @@ binding in ESM. Both include declarations. The binding supports password reveal,
72
72
  without duplicate native feedback, presented-control write notifications and touch, and optional
73
73
  blur deferral through footer presses. See [Native binding](./guides/form.md#native-binding).
74
74
 
75
+ Use `control: 'list'` for free-text tags or recipient arrays. Its `required` rule requires an entry,
76
+ `minimum` and `maximum` count entries, and `email: true` checks each address. Bind it to a native
77
+ `select multiple` for repeated FormData values and authored-option reset. See
78
+ [List fields](./guides/form.md#list).
79
+
75
80
  ## Development
76
81
 
77
82
  ```sh
@@ -126,11 +126,12 @@ declare interface FieldError {
126
126
  * `minimum` and `maximum` measure whatever the control makes countable: characters for
127
127
  * `text`, `editor`, and `password`; magnitude for `number`; chronology for `date`, `time`,
128
128
  * and `datetime`, whose operand is a string written in that control's own format; and the
129
- * number of selections for `checkbox` and `file`.
129
+ * number of entries for `checkbox`, `list`, and `file`.
130
130
  *
131
131
  * `step` is the interval a numeric value must land on. `pattern` is regular-expression
132
132
  * source. `email`, `url`, `integer`, and `alphanumeric` each assert one shape over the whole
133
- * value. `custom` runs last and is the only rule that sees the rest of the form.
133
+ * value, or each entry of a `list`. `required` requires an entry for `list` and presence
134
+ * for other controls. `custom` runs last and is the only rule that sees the rest of the form.
134
135
  *
135
136
  * @example
136
137
  * ```ts
@@ -189,7 +190,7 @@ declare type FieldValidator = (value: FieldValue | undefined, values: FormValues
189
190
  *
190
191
  * @remarks
191
192
  * The variant follows the control: text-like controls hold a `string`, `number` holds a
192
- * `number`, `confirm` holds a `boolean`, and `checkbox` holds its checked values as a
193
+ * `number`, `confirm` holds a `boolean`, and `checkbox`, `list`, and `file` hold a
193
194
  * `readonly string[]`.
194
195
  */
195
196
  declare type FieldValue = string | number | boolean | readonly string[];
@@ -283,7 +284,7 @@ export declare interface FormBindingInterface {
283
284
  export declare interface FormBindingOptions {
284
285
  readonly feedback?: ReadonlyMap<string, HTMLElement>;
285
286
  readonly summary?: HTMLElement;
286
- /** Maps native controls to focus targets whose departure touches the represented field. */
287
+ /** Maps native controls to feedback and summary focus targets whose departure touches the field. */
287
288
  readonly presentations?: ReadonlyMap<FormControl, HTMLElement>;
288
289
  /** Defers blur touch until a same-form button's primary pointer press finishes. Default: false. */
289
290
  readonly defer?: boolean;
@@ -339,7 +340,7 @@ declare type FormEventMap = {
339
340
  * }
340
341
  * ```
341
342
  */
342
- declare type FormField = TextField | EditorField | PasswordField | NumberField | DateField | TimeField | DatetimeField | ColorField | ConfirmField | SelectField | CheckboxField | FileField;
343
+ declare type FormField = TextField | EditorField | PasswordField | NumberField | DateField | TimeField | DatetimeField | ColorField | ConfirmField | SelectField | CheckboxField | ListField | FileField;
343
344
 
344
345
  /**
345
346
  * Represents a named section of a form.
@@ -656,6 +657,26 @@ export declare function isFormBindingError(value: unknown): value is FormBinding
656
657
  */
657
658
  export declare function isFormControl(value: unknown): value is FormControl;
658
659
 
660
+ /**
661
+ * Represents an ordered list of free-text strings.
662
+ *
663
+ * @remarks
664
+ * `required` requires at least one entry. `minimum` and `maximum` count entries.
665
+ * `pattern`, `email`, `url`, `integer`, and `alphanumeric` check each entry and name
666
+ * the failing value in its message. `custom` checks the whole list. Duplicates are retained.
667
+ *
668
+ * @example
669
+ * ```ts
670
+ * const to: ListField = {
671
+ * control: 'list', name: 'to', rule: { required: true, email: true },
672
+ * }
673
+ * ```
674
+ */
675
+ declare interface ListField extends FieldBase {
676
+ readonly control: 'list';
677
+ readonly default?: readonly string[];
678
+ }
679
+
659
680
  /**
660
681
  * Checks a native control population against a field's declared value shape.
661
682
  * @param field - The declared field.
@@ -42,6 +42,7 @@ Object.freeze([
42
42
  "confirm",
43
43
  "select",
44
44
  "checkbox",
45
+ "list",
45
46
  "file"
46
47
  ]);
47
48
  /** Lists the members every field declares, whatever its control. */
@@ -90,6 +91,7 @@ Object.freeze({
90
91
  "choices",
91
92
  "default"
92
93
  ]),
94
+ list: Object.freeze([...FIELD_BASE_KEYS, "default"]),
93
95
  file: Object.freeze([
94
96
  ...FIELD_BASE_KEYS,
95
97
  "accept",
@@ -161,6 +163,7 @@ function matchesField(field, value) {
161
163
  case "confirm": return isBoolean(value);
162
164
  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
165
  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;
166
+ case "list": return entries !== void 0;
164
167
  case "file": return entries !== void 0 && (field.multiple === true || entries.length <= 1);
165
168
  }
166
169
  }
@@ -171,7 +174,7 @@ function matchesField(field, value) {
171
174
  * Bind with `fill(name, matchesAnswer(raw) ? raw : undefined)`. This projection treats an absent
172
175
  * value and a string containing only whitespace as unanswered. Every other field value is an
173
176
  * answer, including an empty list, `false`, and zero. Core evaluation does not use this projection:
174
- * its `required` rule remains presence-only.
177
+ * its `required` rule remains presence-only except that `list` requires an entry.
175
178
  *
176
179
  * @param value - The raw field value, or absence.
177
180
  * @returns True if the binding preserves the value as an answer; false otherwise.
@@ -223,7 +226,7 @@ function matchesFormControls(field, controls) {
223
226
  if (controls.length > 1 && field.control !== "checkbox" && field.control !== "select") return false;
224
227
  return controls.every((control) => {
225
228
  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");
229
+ if (control instanceof HTMLSelectElement) return controls.length === 1 && (control.multiple ? field.control === "checkbox" || field.control === "list" : field.control === "select");
227
230
  switch (field.control) {
228
231
  case "text": return [
229
232
  "text",
@@ -300,7 +303,8 @@ function readFormControls(field, controls) {
300
303
  const first = controls[0];
301
304
  if (first === void 0) return void 0;
302
305
  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);
306
+ if (field.control === "list" && first instanceof HTMLSelectElement) value = Array.from(first.selectedOptions).filter((option) => !option.matches(":disabled")).map((option) => option.value);
307
+ 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
308
  else if (field.control === "select" && first instanceof HTMLInputElement) value = controls.find((control) => control instanceof HTMLInputElement && control.checked)?.value;
305
309
  else if (field.control === "confirm" && first instanceof HTMLInputElement) value = first.checked;
306
310
  else if (field.control === "number" && first instanceof HTMLInputElement) {
@@ -344,6 +348,8 @@ var FormBinding = class {
344
348
  #unreadable = /* @__PURE__ */ new Set();
345
349
  #names = /* @__PURE__ */ new Map();
346
350
  #readings = /* @__PURE__ */ new Map();
351
+ #lists = /* @__PURE__ */ new Map();
352
+ #resets = /* @__PURE__ */ new Map();
347
353
  #deferred = /* @__PURE__ */ new Set();
348
354
  #refresh = this.#onState.bind(this);
349
355
  #fill = this.#onFill.bind(this);
@@ -536,6 +542,8 @@ var FormBinding = class {
536
542
  this.#feedback.clear();
537
543
  this.#names.clear();
538
544
  this.#readings.clear();
545
+ this.#lists.clear();
546
+ this.#resets.clear();
539
547
  this.#unreadable.clear();
540
548
  this.#logical.clear();
541
549
  this.#controls = /* @__PURE__ */ new Map();
@@ -691,12 +699,24 @@ var FormBinding = class {
691
699
  }
692
700
  this.#attribute(control, "aria-invalid", visible && message !== "" ? "true" : null);
693
701
  if (feedback !== void 0) this.#description(control, feedback.id, visible && message !== "");
702
+ const presentation = this.#options?.presentations?.get(control);
703
+ if (presentation !== void 0) {
704
+ this.#attribute(presentation, "aria-invalid", visible && message !== "" ? "true" : null);
705
+ if (feedback !== void 0) this.#description(presentation, feedback.id, visible && message !== "");
706
+ }
694
707
  }
695
708
  }
696
709
  this.#paintSummary(errors.filter((error) => this.#document.touched.has(error.field) && !this.#document.disabled.has(error.field)));
697
710
  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
711
  }
699
712
  #metadata(field, control) {
713
+ if (field.control === "list" && control instanceof HTMLSelectElement && !this.#lists.has(control)) {
714
+ const snapshot = control.cloneNode(true);
715
+ if (snapshot instanceof HTMLSelectElement) {
716
+ for (const [index, option] of Array.from(snapshot.options).entries()) option.selected = control.options[index]?.selected ?? false;
717
+ this.#lists.set(control, snapshot);
718
+ }
719
+ }
700
720
  if (this.#logical.has(field.name)) this.#attribute(control, "disabled", "");
701
721
  else this.#releaseAttribute(control, "disabled");
702
722
  if (field.locked && !(control instanceof HTMLSelectElement) && !(control instanceof HTMLInputElement && [
@@ -742,6 +762,32 @@ var FormBinding = class {
742
762
  control.checked = checked;
743
763
  changed = true;
744
764
  }
765
+ } else if (control instanceof HTMLSelectElement && field.control === "list") {
766
+ const remaining = Array.from(control.options).filter((option) => !option.matches(":disabled"));
767
+ const selected = [];
768
+ for (const entry of Array.isArray(value) ? value : []) {
769
+ const index = remaining.findIndex((option) => option.value === entry);
770
+ let option = index < 0 ? void 0 : remaining.splice(index, 1)[0];
771
+ if (option === void 0) {
772
+ option = control.ownerDocument.createElement("option");
773
+ option.value = entry;
774
+ option.text = entry;
775
+ control.append(option);
776
+ changed = true;
777
+ }
778
+ if (!option.selected) changed = true;
779
+ option.selected = true;
780
+ selected.push(option);
781
+ }
782
+ for (const option of remaining) {
783
+ if (option.selected) changed = true;
784
+ option.selected = false;
785
+ }
786
+ const ordered = Array.from(control.selectedOptions).filter((option) => !option.matches(":disabled"));
787
+ if (selected.some((option, index) => ordered[index] !== option)) {
788
+ control.append(...selected);
789
+ changed = true;
790
+ }
745
791
  } else if (control instanceof HTMLSelectElement && control.multiple) for (const option of Array.from(control.options)) {
746
792
  const selected = Array.isArray(value) && value.includes(option.value);
747
793
  if (option.selected !== selected) {
@@ -759,6 +805,13 @@ var FormBinding = class {
759
805
  }
760
806
  }
761
807
  this.#readings.set(control, control.value);
808
+ if (control instanceof HTMLSelectElement) {
809
+ const before = this.#resets.get(control);
810
+ if (before !== void 0) {
811
+ this.#resets.delete(control);
812
+ changed = !matchesValue(before, Array.from(control.selectedOptions, (option) => option.value));
813
+ }
814
+ }
762
815
  if (changed) {
763
816
  this.#changed = true;
764
817
  this.#emitter.emit("write", control);
@@ -814,14 +867,21 @@ var FormBinding = class {
814
867
  }
815
868
  #releaseRemoved() {
816
869
  const live = new Set([...this.#controls.values()].flat());
870
+ const presentations = new Set([...live].map((control) => this.#options?.presentations?.get(control)));
817
871
  for (const [control, name] of this.#names) if (!live.has(control) || control.name !== name) {
872
+ if (control instanceof HTMLSelectElement) {
873
+ this.#lists.delete(control);
874
+ this.#resets.delete(control);
875
+ }
876
+ const presentation = this.#options?.presentations?.get(control);
877
+ if (presentation !== void 0) this.#restore(presentation);
818
878
  this.#restoreValidity(control);
819
879
  this.#restore(control);
820
880
  this.#names.delete(control);
821
881
  this.#readings.delete(control);
822
882
  }
823
883
  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);
884
+ for (const element of this.#attributes.keys()) if (!presentations.has(element) && (isFormControl(element) || element instanceof HTMLButtonElement) && element.form !== this.#element) this.#restore(element);
825
885
  for (const [name, feedback] of this.#feedback) if (!this.#controls.has(name) && !this.#options?.feedback?.has(name)) {
826
886
  feedback.remove();
827
887
  this.#feedback.delete(name);
@@ -860,7 +920,7 @@ var FormBinding = class {
860
920
  destination.append(this.#summary);
861
921
  }
862
922
  #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"));
923
+ 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
924
  }
865
925
  #onDocument(preserve = false) {
866
926
  if (this.#destroyed) return;
@@ -1026,10 +1086,30 @@ var FormBinding = class {
1026
1086
  if (event.defaultPrevented || this.#destroyed) return;
1027
1087
  event.preventDefault();
1028
1088
  try {
1029
- this.#document.clear();
1089
+ const lists = /* @__PURE__ */ new Map();
1090
+ for (const [control, snapshot] of this.#lists) if (control.form === this.#element && control.name === snapshot.name && this.#document.field(control.name)?.control === "list") {
1091
+ lists.set(control, snapshot);
1092
+ this.#resets.set(control, Array.from(control.selectedOptions, (option) => option.value));
1093
+ }
1094
+ this.#reading = true;
1095
+ try {
1096
+ this.#document.clear();
1097
+ for (const [control, snapshot] of lists) {
1098
+ if (this.#destroyed) return;
1099
+ if (control.form !== this.#element || control.name !== snapshot.name) continue;
1100
+ control.replaceChildren(...Array.from(snapshot.childNodes, (node) => node.cloneNode(true)));
1101
+ for (const [index, option] of Array.from(control.options).entries()) option.selected = snapshot.options[index]?.selected ?? false;
1102
+ const field = this.#document.field(control.name);
1103
+ if (field !== void 0) this.#document.fill(field.name, readFormControls(field, [control]));
1104
+ }
1105
+ } finally {
1106
+ this.#reading = false;
1107
+ }
1030
1108
  this.refresh();
1031
1109
  } catch (error) {
1032
1110
  this.#emitter.emit("error", error);
1111
+ } finally {
1112
+ this.#resets.clear();
1033
1113
  }
1034
1114
  }
1035
1115
  };