@orkestrel/form 0.0.10 → 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 +11 -4
- package/dist/src/browser/index.d.ts +49 -4
- package/dist/src/browser/index.js +250 -30
- 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 +2 -2
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
> the answers given against it, declarative `FieldRule` data stating what those answers must
|
|
5
5
|
> satisfy, acknowledged editing, and a terminal submit that settles the form exactly once.
|
|
6
6
|
|
|
7
|
-
A terminal prompt and a browser form ask the same thing in different places, so
|
|
8
|
-
what they share
|
|
7
|
+
A terminal prompt and a browser form ask the same thing in different places, so the core ships
|
|
8
|
+
what they share. The browser entry binds native controls and renders neutral feedback. Its `answer` promise is the parking seam
|
|
9
9
|
a server needs: hand the document out, wait, receive the answers back. A live form can take a field
|
|
10
10
|
out and put it back with `disable` and `enable`, and exported budgets bound what one schema and its
|
|
11
11
|
answers may retain, so a document that arrives from a wire costs a known maximum before anything
|
|
@@ -67,8 +67,15 @@ concept inventory of what this package leaves to the layer above.
|
|
|
67
67
|
|
|
68
68
|
## Package
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
The `.` entry ships the host-independent core in ESM and CJS. The `./browser` entry ships the native
|
|
71
|
+
binding in ESM. Both include declarations. The binding supports password reveal, rule messages
|
|
72
|
+
without duplicate native feedback, presented-control write notifications and touch, and optional
|
|
73
|
+
blur deferral through footer presses. See [Native binding](./guides/form.md#native-binding).
|
|
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).
|
|
72
79
|
|
|
73
80
|
## Development
|
|
74
81
|
|
|
@@ -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[];
|
|
@@ -259,6 +260,8 @@ export declare type FormBindingErrorCode = 'CONTROL' | 'PENDING' | 'DOM';
|
|
|
259
260
|
/** Lists the native binding's notifications after their associated DOM effects. */
|
|
260
261
|
export declare type FormBindingEventMap = {
|
|
261
262
|
readonly render: readonly [];
|
|
263
|
+
/** Reports a control after its native value changes during document projection. */
|
|
264
|
+
readonly write: readonly [control: FormControl];
|
|
262
265
|
readonly submit: readonly [submission: FormSubmission, submitter: HTMLElement | undefined];
|
|
263
266
|
readonly error: readonly [error: unknown];
|
|
264
267
|
};
|
|
@@ -281,6 +284,10 @@ export declare interface FormBindingInterface {
|
|
|
281
284
|
export declare interface FormBindingOptions {
|
|
282
285
|
readonly feedback?: ReadonlyMap<string, HTMLElement>;
|
|
283
286
|
readonly summary?: HTMLElement;
|
|
287
|
+
/** Maps native controls to feedback and summary focus targets whose departure touches the field. */
|
|
288
|
+
readonly presentations?: ReadonlyMap<FormControl, HTMLElement>;
|
|
289
|
+
/** Defers blur touch until a same-form button's primary pointer press finishes. Default: false. */
|
|
290
|
+
readonly defer?: boolean;
|
|
284
291
|
readonly on?: EmitterHooks<FormBindingEventMap>;
|
|
285
292
|
readonly error?: EmitterErrorHandler;
|
|
286
293
|
}
|
|
@@ -333,7 +340,7 @@ declare type FormEventMap = {
|
|
|
333
340
|
* }
|
|
334
341
|
* ```
|
|
335
342
|
*/
|
|
336
|
-
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;
|
|
337
344
|
|
|
338
345
|
/**
|
|
339
346
|
* Represents a named section of a form.
|
|
@@ -650,6 +657,26 @@ export declare function isFormBindingError(value: unknown): value is FormBinding
|
|
|
650
657
|
*/
|
|
651
658
|
export declare function isFormControl(value: unknown): value is FormControl;
|
|
652
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
|
+
|
|
653
680
|
/**
|
|
654
681
|
* Checks a native control population against a field's declared value shape.
|
|
655
682
|
* @param field - The declared field.
|
|
@@ -696,6 +723,24 @@ declare interface PasswordField extends FieldBase {
|
|
|
696
723
|
*/
|
|
697
724
|
export declare function readFormControls(field: FormField, controls: readonly FormControl[]): FieldValue | undefined;
|
|
698
725
|
|
|
726
|
+
/**
|
|
727
|
+
* Reads native constraint messages not already reported by document rules.
|
|
728
|
+
* @param control - The native control with binding-owned custom validity removed.
|
|
729
|
+
* @param errors - The document's reported failures.
|
|
730
|
+
* @returns Uncovered constraint messages, preserving authored custom validity.
|
|
731
|
+
* @remarks
|
|
732
|
+
* A lone native failure retains platform copy. Competing failures use constraint-specific
|
|
733
|
+
* copy because the platform exposes only one message for the whole control.
|
|
734
|
+
* @example
|
|
735
|
+
* ```ts
|
|
736
|
+
* const input = document.createElement('input')
|
|
737
|
+
* input.name = 'name'
|
|
738
|
+
* input.required = true
|
|
739
|
+
* readNativeMessages(input, [{ field: 'name', rule: 'required', message: 'Enter a name' }]) // []
|
|
740
|
+
* ```
|
|
741
|
+
*/
|
|
742
|
+
export declare function readNativeMessages(control: FormControl, errors: readonly FieldError[]): readonly string[];
|
|
743
|
+
|
|
699
744
|
/**
|
|
700
745
|
* Represents one choice out of a list.
|
|
701
746
|
*
|
|
@@ -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",
|
|
@@ -101,7 +103,8 @@ Object.freeze([
|
|
|
101
103
|
"settled",
|
|
102
104
|
"abandoned"
|
|
103
105
|
]);
|
|
104
|
-
|
|
106
|
+
/** Holds the default failure copy for every named field rule. */
|
|
107
|
+
var RULE_MESSAGES = Object.freeze({
|
|
105
108
|
required: "This field is required",
|
|
106
109
|
minimum: "Must be at least {limit}",
|
|
107
110
|
maximum: "Must be at most {limit}",
|
|
@@ -160,6 +163,7 @@ function matchesField(field, value) {
|
|
|
160
163
|
case "confirm": return isBoolean(value);
|
|
161
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));
|
|
162
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;
|
|
163
167
|
case "file": return entries !== void 0 && (field.multiple === true || entries.length <= 1);
|
|
164
168
|
}
|
|
165
169
|
}
|
|
@@ -170,7 +174,7 @@ function matchesField(field, value) {
|
|
|
170
174
|
* Bind with `fill(name, matchesAnswer(raw) ? raw : undefined)`. This projection treats an absent
|
|
171
175
|
* value and a string containing only whitespace as unanswered. Every other field value is an
|
|
172
176
|
* answer, including an empty list, `false`, and zero. Core evaluation does not use this projection:
|
|
173
|
-
* its `required` rule remains presence-only.
|
|
177
|
+
* its `required` rule remains presence-only except that `list` requires an entry.
|
|
174
178
|
*
|
|
175
179
|
* @param value - The raw field value, or absence.
|
|
176
180
|
* @returns True if the binding preserves the value as an answer; false otherwise.
|
|
@@ -189,6 +193,22 @@ function matchesValue(a, b) {
|
|
|
189
193
|
if (isArray(a) || isArray(b)) return isArray(a) && isArray(b) && a.length === b.length && a.every((entry, index) => entry === b[index]);
|
|
190
194
|
return a === b;
|
|
191
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Resolves and interpolates one rule message.
|
|
198
|
+
*
|
|
199
|
+
* @remarks
|
|
200
|
+
* A replacement in `messages` is read first and {@link RULE_MESSAGES} supplies the copy otherwise,
|
|
201
|
+
* and `{limit}` in whichever text wins is replaced with the rule's operand.
|
|
202
|
+
*
|
|
203
|
+
* @param rule - The rule whose message to resolve.
|
|
204
|
+
* @param limit - The optional operand substituted for `{limit}`.
|
|
205
|
+
* @param messages - Optional rule-specific message replacements.
|
|
206
|
+
* @returns The resolved failure text.
|
|
207
|
+
*/
|
|
208
|
+
function formatMessage(rule, limit, messages) {
|
|
209
|
+
const message = messages?.[rule] ?? RULE_MESSAGES[rule];
|
|
210
|
+
return limit === void 0 ? message : message.replaceAll("{limit}", String(limit));
|
|
211
|
+
}
|
|
192
212
|
//#endregion
|
|
193
213
|
//#region src/browser/helpers.ts
|
|
194
214
|
/**
|
|
@@ -206,7 +226,7 @@ function matchesFormControls(field, controls) {
|
|
|
206
226
|
if (controls.length > 1 && field.control !== "checkbox" && field.control !== "select") return false;
|
|
207
227
|
return controls.every((control) => {
|
|
208
228
|
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");
|
|
229
|
+
if (control instanceof HTMLSelectElement) return controls.length === 1 && (control.multiple ? field.control === "checkbox" || field.control === "list" : field.control === "select");
|
|
210
230
|
switch (field.control) {
|
|
211
231
|
case "text": return [
|
|
212
232
|
"text",
|
|
@@ -216,7 +236,7 @@ function matchesFormControls(field, controls) {
|
|
|
216
236
|
"tel",
|
|
217
237
|
"hidden"
|
|
218
238
|
].includes(control.type);
|
|
219
|
-
case "password": return control.type === "password";
|
|
239
|
+
case "password": return control.type === "password" || control.type === "text";
|
|
220
240
|
case "number": return control.type === "number" || control.type === "range";
|
|
221
241
|
case "datetime": return control.type === "datetime-local";
|
|
222
242
|
case "confirm": return control.type === "checkbox";
|
|
@@ -228,6 +248,44 @@ function matchesFormControls(field, controls) {
|
|
|
228
248
|
});
|
|
229
249
|
}
|
|
230
250
|
/**
|
|
251
|
+
* Reads native constraint messages not already reported by document rules.
|
|
252
|
+
* @param control - The native control with binding-owned custom validity removed.
|
|
253
|
+
* @param errors - The document's reported failures.
|
|
254
|
+
* @returns Uncovered constraint messages, preserving authored custom validity.
|
|
255
|
+
* @remarks
|
|
256
|
+
* A lone native failure retains platform copy. Competing failures use constraint-specific
|
|
257
|
+
* copy because the platform exposes only one message for the whole control.
|
|
258
|
+
* @example
|
|
259
|
+
* ```ts
|
|
260
|
+
* const input = document.createElement('input')
|
|
261
|
+
* input.name = 'name'
|
|
262
|
+
* input.required = true
|
|
263
|
+
* readNativeMessages(input, [{ field: 'name', rule: 'required', message: 'Enter a name' }]) // []
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
function readNativeMessages(control, errors) {
|
|
267
|
+
const rules = new Set(errors.filter((error) => error.field === control.name).map((error) => error.rule));
|
|
268
|
+
const validity = control.validity;
|
|
269
|
+
const failures = /* @__PURE__ */ new Map();
|
|
270
|
+
if (validity.valueMissing) failures.set("required", formatMessage("required"));
|
|
271
|
+
if (validity.rangeUnderflow) failures.set("minimum", formatMessage("minimum", control.getAttribute("min") ?? ""));
|
|
272
|
+
if (validity.rangeOverflow) failures.set("maximum", formatMessage("maximum", control.getAttribute("max") ?? ""));
|
|
273
|
+
if (validity.tooShort) failures.set("minimum", formatMessage("minimum", control.getAttribute("minlength") ?? ""));
|
|
274
|
+
if (validity.tooLong) failures.set("maximum", formatMessage("maximum", control.getAttribute("maxlength") ?? ""));
|
|
275
|
+
if (validity.stepMismatch) failures.set("step", "Use a permitted value for this step");
|
|
276
|
+
if (validity.patternMismatch) failures.set("pattern", formatMessage("pattern"));
|
|
277
|
+
if (validity.typeMismatch && control instanceof HTMLInputElement) {
|
|
278
|
+
if (control.type === "email") failures.set("email", formatMessage("email"));
|
|
279
|
+
else if (control.type === "url") failures.set("url", formatMessage("url"));
|
|
280
|
+
}
|
|
281
|
+
const messages = [];
|
|
282
|
+
if (validity.customError) messages.push(control.validationMessage);
|
|
283
|
+
const lone = failures.size + Number(validity.customError) + Number(validity.badInput) === 1;
|
|
284
|
+
if (validity.badInput) messages.push(lone ? control.validationMessage : "Enter a valid value");
|
|
285
|
+
for (const [rule, message] of failures) if (!rules.has(rule)) messages.push(lone ? control.validationMessage : message);
|
|
286
|
+
return messages;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
231
289
|
* Reads one compatible native population using the document's blank-to-absence semantics.
|
|
232
290
|
* @param field - The field declaring the answer shape.
|
|
233
291
|
* @param controls - Controls sharing the field's name, in native order.
|
|
@@ -245,7 +303,8 @@ function readFormControls(field, controls) {
|
|
|
245
303
|
const first = controls[0];
|
|
246
304
|
if (first === void 0) return void 0;
|
|
247
305
|
let value;
|
|
248
|
-
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);
|
|
249
308
|
else if (field.control === "select" && first instanceof HTMLInputElement) value = controls.find((control) => control instanceof HTMLInputElement && control.checked)?.value;
|
|
250
309
|
else if (field.control === "confirm" && first instanceof HTMLInputElement) value = first.checked;
|
|
251
310
|
else if (field.control === "number" && first instanceof HTMLInputElement) {
|
|
@@ -288,6 +347,10 @@ var FormBinding = class {
|
|
|
288
347
|
#logical = /* @__PURE__ */ new Set();
|
|
289
348
|
#unreadable = /* @__PURE__ */ new Set();
|
|
290
349
|
#names = /* @__PURE__ */ new Map();
|
|
350
|
+
#readings = /* @__PURE__ */ new Map();
|
|
351
|
+
#lists = /* @__PURE__ */ new Map();
|
|
352
|
+
#resets = /* @__PURE__ */ new Map();
|
|
353
|
+
#deferred = /* @__PURE__ */ new Set();
|
|
291
354
|
#refresh = this.#onState.bind(this);
|
|
292
355
|
#fill = this.#onFill.bind(this);
|
|
293
356
|
#disable = this.#onDisable.bind(this);
|
|
@@ -299,13 +362,15 @@ var FormBinding = class {
|
|
|
299
362
|
#summaryText = "";
|
|
300
363
|
#destroyed = false;
|
|
301
364
|
#running = false;
|
|
302
|
-
#
|
|
365
|
+
#preserve;
|
|
303
366
|
#reading = false;
|
|
304
367
|
#excluding;
|
|
305
368
|
#changed = false;
|
|
306
369
|
#notified = false;
|
|
307
370
|
#scheduled = false;
|
|
308
371
|
#submitting = false;
|
|
372
|
+
#pointer;
|
|
373
|
+
#release;
|
|
309
374
|
/**
|
|
310
375
|
* Binds existing controls to the supplied document's answers.
|
|
311
376
|
* @param element - The native form, including its externally associated controls.
|
|
@@ -318,7 +383,7 @@ var FormBinding = class {
|
|
|
318
383
|
this.#document = document;
|
|
319
384
|
this.#options = options;
|
|
320
385
|
this.#emitter = new Emitter(options);
|
|
321
|
-
this.#observer = new MutationObserver(() => this.#onDocument());
|
|
386
|
+
this.#observer = new MutationObserver(() => this.#onDocument(true));
|
|
322
387
|
for (const name of document.disabled) this.#logical.add(name);
|
|
323
388
|
try {
|
|
324
389
|
this.#controls = this.#discover();
|
|
@@ -329,6 +394,21 @@ var FormBinding = class {
|
|
|
329
394
|
element.ownerDocument.addEventListener("change", (event) => this.#onInput(event), { signal });
|
|
330
395
|
element.ownerDocument.addEventListener("focusout", (event) => this.#onBlur(event), { signal });
|
|
331
396
|
element.ownerDocument.addEventListener("click", (event) => this.#onClick(event), { signal });
|
|
397
|
+
if (options?.defer) {
|
|
398
|
+
element.ownerDocument.addEventListener("pointerdown", (event) => this.#onPress(event), {
|
|
399
|
+
signal,
|
|
400
|
+
capture: true
|
|
401
|
+
});
|
|
402
|
+
element.ownerDocument.addEventListener("pointerup", (event) => this.#onRelease(event), {
|
|
403
|
+
signal,
|
|
404
|
+
capture: true
|
|
405
|
+
});
|
|
406
|
+
element.ownerDocument.addEventListener("pointercancel", (event) => this.#onRelease(event), {
|
|
407
|
+
signal,
|
|
408
|
+
capture: true
|
|
409
|
+
});
|
|
410
|
+
element.ownerDocument.defaultView?.addEventListener("blur", () => this.#finishPress(), { signal });
|
|
411
|
+
}
|
|
332
412
|
document.emitter.on("fill", this.#fill);
|
|
333
413
|
document.emitter.on("disable", this.#disable);
|
|
334
414
|
document.emitter.on("enable", this.#enable);
|
|
@@ -433,6 +513,7 @@ var FormBinding = class {
|
|
|
433
513
|
this.#destroyed = true;
|
|
434
514
|
this.#observer.disconnect();
|
|
435
515
|
this.#listeners.abort();
|
|
516
|
+
this.#clearPress();
|
|
436
517
|
this.#document.emitter.off("fill", this.#fill);
|
|
437
518
|
this.#document.emitter.off("disable", this.#disable);
|
|
438
519
|
this.#document.emitter.off("enable", this.#enable);
|
|
@@ -460,27 +541,31 @@ var FormBinding = class {
|
|
|
460
541
|
this.#excluded.clear();
|
|
461
542
|
this.#feedback.clear();
|
|
462
543
|
this.#names.clear();
|
|
544
|
+
this.#readings.clear();
|
|
545
|
+
this.#lists.clear();
|
|
546
|
+
this.#resets.clear();
|
|
463
547
|
this.#unreadable.clear();
|
|
464
548
|
this.#logical.clear();
|
|
465
549
|
this.#controls = /* @__PURE__ */ new Map();
|
|
466
550
|
this.#summary = void 0;
|
|
467
551
|
this.#emitter.destroy();
|
|
468
552
|
}
|
|
469
|
-
#drain() {
|
|
553
|
+
#drain(preserve = false) {
|
|
470
554
|
if (this.#destroyed) return;
|
|
471
|
-
this.#
|
|
555
|
+
this.#preserve = this.#preserve === false ? false : preserve;
|
|
472
556
|
if (this.#running || this.#reading) return;
|
|
473
557
|
this.#running = true;
|
|
474
558
|
try {
|
|
475
559
|
let turns = 0;
|
|
476
|
-
while (this.#
|
|
477
|
-
|
|
560
|
+
while (this.#preserve !== void 0 && !this.#destroyed && turns < 2) {
|
|
561
|
+
const retaining = this.#preserve;
|
|
562
|
+
this.#preserve = void 0;
|
|
478
563
|
this.#changed = false;
|
|
479
564
|
this.#controls = this.#discover();
|
|
480
565
|
this.#releaseRemoved();
|
|
481
566
|
this.#exclusions();
|
|
482
567
|
if (this.#destroyed) break;
|
|
483
|
-
this.#paint();
|
|
568
|
+
this.#paint(retaining);
|
|
484
569
|
if (!this.#destroyed && (this.#changed || !this.#notified)) {
|
|
485
570
|
this.#notified = true;
|
|
486
571
|
this.#emitter.emit("render");
|
|
@@ -488,15 +573,15 @@ var FormBinding = class {
|
|
|
488
573
|
turns += 1;
|
|
489
574
|
}
|
|
490
575
|
} catch (error) {
|
|
491
|
-
this.#
|
|
576
|
+
this.#preserve = void 0;
|
|
492
577
|
throw isFormBindingError(error) ? error : new FormBindingError("DOM", "Native form projection failed", { cause: error });
|
|
493
578
|
} finally {
|
|
494
579
|
this.#running = false;
|
|
495
|
-
if (this.#
|
|
580
|
+
if (this.#preserve !== void 0 && !this.#destroyed && !this.#scheduled) {
|
|
496
581
|
this.#scheduled = true;
|
|
497
582
|
queueMicrotask(() => {
|
|
498
583
|
this.#scheduled = false;
|
|
499
|
-
if (!this.#destroyed) this.#onDocument();
|
|
584
|
+
if (!this.#destroyed) this.#onDocument(this.#preserve ?? false);
|
|
500
585
|
});
|
|
501
586
|
}
|
|
502
587
|
}
|
|
@@ -560,7 +645,7 @@ var FormBinding = class {
|
|
|
560
645
|
}
|
|
561
646
|
}
|
|
562
647
|
}
|
|
563
|
-
#paint() {
|
|
648
|
+
#paint(preserve) {
|
|
564
649
|
this.#attribute(this.#element, "novalidate", "");
|
|
565
650
|
this.#attribute(this.#element, "aria-busy", this.#document.pending === void 0 ? "false" : "true");
|
|
566
651
|
const errors = [...this.#document.errors];
|
|
@@ -570,11 +655,12 @@ var FormBinding = class {
|
|
|
570
655
|
const group = this.#controls.get(field.name) ?? [];
|
|
571
656
|
for (const control of group) {
|
|
572
657
|
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);
|
|
658
|
+
if (!this.#unreadable.has(field.name)) this.#value(field, control, Object.hasOwn(values, field.name) ? values[field.name] : void 0, preserve);
|
|
659
|
+
if (this.#destroyed) return;
|
|
574
660
|
this.#restoreValidity(control);
|
|
575
|
-
if (!this.#document.disabled.has(field.name) && !control.validity.valid && control.willValidate) errors.push({
|
|
661
|
+
if (!this.#document.disabled.has(field.name) && !control.validity.valid && control.willValidate) for (const message of readNativeMessages(control, this.#document.errors)) errors.push({
|
|
576
662
|
field: field.name,
|
|
577
|
-
message
|
|
663
|
+
message
|
|
578
664
|
});
|
|
579
665
|
}
|
|
580
666
|
}
|
|
@@ -613,12 +699,24 @@ var FormBinding = class {
|
|
|
613
699
|
}
|
|
614
700
|
this.#attribute(control, "aria-invalid", visible && message !== "" ? "true" : null);
|
|
615
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
|
+
}
|
|
616
707
|
}
|
|
617
708
|
}
|
|
618
709
|
this.#paintSummary(errors.filter((error) => this.#document.touched.has(error.field) && !this.#document.disabled.has(error.field)));
|
|
619
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");
|
|
620
711
|
}
|
|
621
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
|
+
}
|
|
622
720
|
if (this.#logical.has(field.name)) this.#attribute(control, "disabled", "");
|
|
623
721
|
else this.#releaseAttribute(control, "disabled");
|
|
624
722
|
if (field.locked && !(control instanceof HTMLSelectElement) && !(control instanceof HTMLInputElement && [
|
|
@@ -639,12 +737,22 @@ var FormBinding = class {
|
|
|
639
737
|
if (field.control === "number" && field.rule?.step !== void 0 && !control.hasAttribute("step")) this.#attribute(control, "step", String(field.rule.step));
|
|
640
738
|
}
|
|
641
739
|
}
|
|
642
|
-
#value(field, control, value) {
|
|
740
|
+
#value(field, control, value, preserve) {
|
|
741
|
+
if (preserve && !field.locked && control.ownerDocument.activeElement === control && this.#readings.has(control) && (control instanceof HTMLTextAreaElement || control instanceof HTMLInputElement && [
|
|
742
|
+
"text",
|
|
743
|
+
"search",
|
|
744
|
+
"email",
|
|
745
|
+
"url",
|
|
746
|
+
"tel",
|
|
747
|
+
"password"
|
|
748
|
+
].includes(control.type)) && control.value !== this.#readings.get(control)) return;
|
|
749
|
+
let changed = false;
|
|
643
750
|
if (control instanceof HTMLInputElement && control.type === "file") {
|
|
644
751
|
const names = Array.from(control.files ?? [], (file) => file.name);
|
|
645
752
|
if (names.length > 0 && (value === void 0 || !matchesValue(names, value))) {
|
|
646
753
|
control.value = "";
|
|
647
754
|
this.#changed = true;
|
|
755
|
+
this.#emitter.emit("write", control);
|
|
648
756
|
}
|
|
649
757
|
return;
|
|
650
758
|
}
|
|
@@ -652,13 +760,39 @@ var FormBinding = class {
|
|
|
652
760
|
const checked = field.control === "confirm" ? value === true : Array.isArray(value) ? value.includes(control.value) : value === control.value;
|
|
653
761
|
if (control.checked !== checked) {
|
|
654
762
|
control.checked = checked;
|
|
655
|
-
|
|
763
|
+
changed = true;
|
|
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;
|
|
656
790
|
}
|
|
657
791
|
} else if (control instanceof HTMLSelectElement && control.multiple) for (const option of Array.from(control.options)) {
|
|
658
792
|
const selected = Array.isArray(value) && value.includes(option.value);
|
|
659
793
|
if (option.selected !== selected) {
|
|
660
794
|
option.selected = selected;
|
|
661
|
-
|
|
795
|
+
changed = true;
|
|
662
796
|
}
|
|
663
797
|
}
|
|
664
798
|
else {
|
|
@@ -667,9 +801,21 @@ var FormBinding = class {
|
|
|
667
801
|
const before = control.value;
|
|
668
802
|
const invalid = control.validity.badInput;
|
|
669
803
|
control.value = text;
|
|
670
|
-
if (control.value !== before || invalid)
|
|
804
|
+
if (control.value !== before || invalid) changed = true;
|
|
671
805
|
}
|
|
672
806
|
}
|
|
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
|
+
}
|
|
815
|
+
if (changed) {
|
|
816
|
+
this.#changed = true;
|
|
817
|
+
this.#emitter.emit("write", control);
|
|
818
|
+
}
|
|
673
819
|
}
|
|
674
820
|
#attribute(element, name, value) {
|
|
675
821
|
const attributes = this.#attributes.get(element) ?? /* @__PURE__ */ new Map();
|
|
@@ -721,13 +867,21 @@ var FormBinding = class {
|
|
|
721
867
|
}
|
|
722
868
|
#releaseRemoved() {
|
|
723
869
|
const live = new Set([...this.#controls.values()].flat());
|
|
870
|
+
const presentations = new Set([...live].map((control) => this.#options?.presentations?.get(control)));
|
|
724
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);
|
|
725
878
|
this.#restoreValidity(control);
|
|
726
879
|
this.#restore(control);
|
|
727
880
|
this.#names.delete(control);
|
|
881
|
+
this.#readings.delete(control);
|
|
728
882
|
}
|
|
729
883
|
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);
|
|
884
|
+
for (const element of this.#attributes.keys()) if (!presentations.has(element) && (isFormControl(element) || element instanceof HTMLButtonElement) && element.form !== this.#element) this.#restore(element);
|
|
731
885
|
for (const [name, feedback] of this.#feedback) if (!this.#controls.has(name) && !this.#options?.feedback?.has(name)) {
|
|
732
886
|
feedback.remove();
|
|
733
887
|
this.#feedback.delete(name);
|
|
@@ -766,12 +920,12 @@ var FormBinding = class {
|
|
|
766
920
|
destination.append(this.#summary);
|
|
767
921
|
}
|
|
768
922
|
#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"));
|
|
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"));
|
|
770
924
|
}
|
|
771
|
-
#onDocument() {
|
|
925
|
+
#onDocument(preserve = false) {
|
|
772
926
|
if (this.#destroyed) return;
|
|
773
927
|
try {
|
|
774
|
-
this.#drain();
|
|
928
|
+
this.#drain(preserve);
|
|
775
929
|
} catch (error) {
|
|
776
930
|
this.#emitter.emit("error", error);
|
|
777
931
|
}
|
|
@@ -796,6 +950,7 @@ var FormBinding = class {
|
|
|
796
950
|
this.#onState();
|
|
797
951
|
}
|
|
798
952
|
#onClear() {
|
|
953
|
+
this.#clearPress();
|
|
799
954
|
this.#logical.clear();
|
|
800
955
|
this.#excluded.clear();
|
|
801
956
|
this.#unreadable.clear();
|
|
@@ -828,8 +983,17 @@ var FormBinding = class {
|
|
|
828
983
|
].includes(control.type)) event.preventDefault();
|
|
829
984
|
}
|
|
830
985
|
#onBlur(event) {
|
|
831
|
-
|
|
986
|
+
let control = event.target;
|
|
987
|
+
for (const [native, presentation] of this.#options?.presentations ?? []) if (control === presentation) {
|
|
988
|
+
control = native;
|
|
989
|
+
break;
|
|
990
|
+
}
|
|
832
991
|
if (!isFormControl(control) || control.form !== this.#element || !this.#document.field(control.name) || this.#destroyed || this.#document.status !== "editing") return;
|
|
992
|
+
if (event.relatedTarget === this.#options?.presentations?.get(control)) return;
|
|
993
|
+
if (this.#pointer !== void 0) {
|
|
994
|
+
this.#deferred.add(control.name);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
833
997
|
try {
|
|
834
998
|
this.#document.touch(control.name);
|
|
835
999
|
this.#drain();
|
|
@@ -837,6 +1001,42 @@ var FormBinding = class {
|
|
|
837
1001
|
this.#emitter.emit("error", error);
|
|
838
1002
|
}
|
|
839
1003
|
}
|
|
1004
|
+
#onPress(event) {
|
|
1005
|
+
if (!event.isPrimary || event.button !== 0 || !(event.target instanceof Element)) return;
|
|
1006
|
+
const button = event.target.closest("button,input");
|
|
1007
|
+
if (!((button instanceof HTMLButtonElement || button instanceof HTMLInputElement && [
|
|
1008
|
+
"button",
|
|
1009
|
+
"submit",
|
|
1010
|
+
"reset",
|
|
1011
|
+
"image"
|
|
1012
|
+
].includes(button.type)) && button.form === this.#element && !button.matches(":disabled"))) return;
|
|
1013
|
+
this.#finishPress();
|
|
1014
|
+
this.#pointer = event.pointerId;
|
|
1015
|
+
}
|
|
1016
|
+
#onRelease(event) {
|
|
1017
|
+
if (event.pointerId !== this.#pointer) return;
|
|
1018
|
+
if (this.#release !== void 0) clearTimeout(this.#release);
|
|
1019
|
+
this.#release = setTimeout(() => this.#finishPress(), 0);
|
|
1020
|
+
}
|
|
1021
|
+
#finishPress() {
|
|
1022
|
+
const names = [...this.#deferred];
|
|
1023
|
+
this.#clearPress();
|
|
1024
|
+
if (this.#destroyed || this.#document.status !== "editing") return;
|
|
1025
|
+
try {
|
|
1026
|
+
for (const name of names) {
|
|
1027
|
+
if (this.#destroyed) break;
|
|
1028
|
+
this.#document.touch(name);
|
|
1029
|
+
}
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
this.#emitter.emit("error", error);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
#clearPress() {
|
|
1035
|
+
if (this.#release !== void 0) clearTimeout(this.#release);
|
|
1036
|
+
this.#release = void 0;
|
|
1037
|
+
this.#pointer = void 0;
|
|
1038
|
+
this.#deferred.clear();
|
|
1039
|
+
}
|
|
840
1040
|
#onSubmit(event) {
|
|
841
1041
|
if (event.defaultPrevented || this.#destroyed) return;
|
|
842
1042
|
event.preventDefault();
|
|
@@ -886,10 +1086,30 @@ var FormBinding = class {
|
|
|
886
1086
|
if (event.defaultPrevented || this.#destroyed) return;
|
|
887
1087
|
event.preventDefault();
|
|
888
1088
|
try {
|
|
889
|
-
|
|
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
|
+
}
|
|
890
1108
|
this.refresh();
|
|
891
1109
|
} catch (error) {
|
|
892
1110
|
this.#emitter.emit("error", error);
|
|
1111
|
+
} finally {
|
|
1112
|
+
this.#resets.clear();
|
|
893
1113
|
}
|
|
894
1114
|
}
|
|
895
1115
|
};
|
|
@@ -911,6 +1131,6 @@ function createFormBinding(element, document, options) {
|
|
|
911
1131
|
return new FormBinding(element, document, options);
|
|
912
1132
|
}
|
|
913
1133
|
//#endregion
|
|
914
|
-
export { FormBinding, FormBindingError, createFormBinding, isFormBindingError, isFormControl, matchesFormControls, readFormControls };
|
|
1134
|
+
export { FormBinding, FormBindingError, createFormBinding, isFormBindingError, isFormControl, matchesFormControls, readFormControls, readNativeMessages };
|
|
915
1135
|
|
|
916
1136
|
//# sourceMappingURL=index.js.map
|