@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 +5 -0
- package/dist/src/browser/index.d.ts +26 -5
- package/dist/src/browser/index.js +86 -6
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +62 -14
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +27 -6
- package/dist/src/core/index.d.ts +27 -6
- package/dist/src/core/index.js +62 -14
- package/dist/src/core/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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`
|
|
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
|
|
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 === "
|
|
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
|
-
|
|
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
|
};
|