@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 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 this package ships
8
- what they share and neither renders nor reads input itself. Its `answer` promise is the parking seam
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
- Published as one entry point per the `exports` field in `package.json`: `.`, the host-independent
71
- core. It ships dual ESM+CJS builds with declarations for both.
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 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[];
@@ -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
- Object.freeze({
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 === "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);
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
- #queued = false;
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.#queued = true;
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.#queued && !this.#destroyed && turns < 2) {
477
- this.#queued = false;
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.#queued = false;
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.#queued && !this.#destroyed && !this.#scheduled) {
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: control.validationMessage
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
- this.#changed = true;
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
- this.#changed = true;
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) this.#changed = true;
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
- const control = event.target;
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
- 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
+ }
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