@samline/forms 2.3.4 → 2.6.0

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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -7
  3. package/dist/browser/global.d.ts +10 -16
  4. package/dist/browser/global.global.js +328 -51
  5. package/dist/browser/global.global.js.map +1 -1
  6. package/dist/browser/index.cjs +1309 -0
  7. package/dist/browser/index.cjs.map +1 -0
  8. package/dist/browser/index.d.cts +14 -0
  9. package/dist/browser/index.d.ts +14 -0
  10. package/dist/browser/index.js +23 -0
  11. package/dist/browser/index.js.map +1 -0
  12. package/dist/chunk-L4GRKSQ5.js +1270 -0
  13. package/dist/chunk-L4GRKSQ5.js.map +1 -0
  14. package/dist/index.cjs +143 -25
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +4 -158
  17. package/dist/index.d.ts +4 -158
  18. package/dist/index.js +7 -1141
  19. package/dist/index.js.map +1 -1
  20. package/dist/registry-BpqJetAt.d.cts +148 -0
  21. package/dist/registry-BpqJetAt.d.ts +148 -0
  22. package/docs/api/append.md +96 -0
  23. package/docs/api/auto-submit.md +91 -0
  24. package/docs/api/clear-errors.md +69 -0
  25. package/docs/api/destroy.md +65 -0
  26. package/docs/api/disable-auto-submit.md +65 -0
  27. package/docs/api/element.md +78 -0
  28. package/docs/api/form.md +154 -0
  29. package/docs/api/format.md +270 -0
  30. package/docs/api/get-data.md +90 -0
  31. package/docs/api/get-field.md +87 -0
  32. package/docs/api/get-state.md +95 -0
  33. package/docs/api/get-value.md +114 -0
  34. package/docs/api/index.md +54 -0
  35. package/docs/api/observe.md +126 -0
  36. package/docs/api/on-submit.md +90 -0
  37. package/docs/api/parse-form-data.md +94 -0
  38. package/docs/api/prefill.md +89 -0
  39. package/docs/api/regex.md +155 -0
  40. package/docs/api/reset.md +60 -0
  41. package/docs/api/revalidate.md +50 -0
  42. package/docs/api/set-errors.md +97 -0
  43. package/docs/api/set-value.md +120 -0
  44. package/docs/api/subscribe.md +91 -0
  45. package/docs/api/unwatch.md +74 -0
  46. package/docs/api/validate-field-value.md +108 -0
  47. package/docs/api/validate-values.md +100 -0
  48. package/docs/api/validate.md +100 -0
  49. package/docs/api/watch.md +117 -0
  50. package/docs/browser.md +156 -0
  51. package/docs/css-styling.md +187 -0
  52. package/docs/getting-started.md +217 -0
  53. package/docs/options.md +259 -0
  54. package/docs/recipes.md +477 -0
  55. package/docs/typescript.md +576 -0
  56. package/docs/vanilla.md +12 -0
  57. package/package.json +60 -16
package/dist/index.cjs CHANGED
@@ -45,7 +45,7 @@ var createAppend = (state2, helpers) => ({ tag, content, class: className, atSta
45
45
  helpers.clearFieldCache();
46
46
  if (className) {
47
47
  const classSelector = className.trim().split(/\s+/)[0];
48
- const existing = classSelector ? state2.element.querySelector(`.${classSelector}`) : null;
48
+ const existing = classSelector ? state2.element.getElementsByClassName(classSelector)[0] : null;
49
49
  existing?.remove();
50
50
  }
51
51
  const node = document.createElement(tag);
@@ -62,6 +62,10 @@ var createAppend = (state2, helpers) => ({ tag, content, class: className, atSta
62
62
  // src/api/auto-submit.ts
63
63
  var createAutoSubmit = (state2, helpers) => (next = true) => {
64
64
  state2.autoSubmitEnabled = next !== false;
65
+ if (!state2.autoSubmitEnabled && state2.autoSubmitTimer) {
66
+ clearTimeout(state2.autoSubmitTimer);
67
+ state2.autoSubmitTimer = null;
68
+ }
65
69
  state2.autoSubmitDebounce = typeof next === "object" && next ? next.debounce ?? 0 : 0;
66
70
  helpers.notifySubscribers();
67
71
  return state2.api;
@@ -272,6 +276,11 @@ var readInputType = (event) => {
272
276
  const candidate = event;
273
277
  return typeof candidate.inputType === "string" ? candidate.inputType : void 0;
274
278
  };
279
+ var buildAutoInterpretedOptions = (formatOptions) => ({
280
+ ...formatOptions ?? {},
281
+ interpretInputAs: "auto"
282
+ });
283
+ var shouldForceAutoInterpretation = (formatOptions) => formatOptions?.interpretInputAs === void 0;
275
284
  var buildHandler = (visible, mirror, formatFn, formatType, formatOptions) => {
276
285
  const handler = (event) => {
277
286
  if (!visible.isConnected || !mirror.isConnected) return;
@@ -280,7 +289,8 @@ var buildHandler = (visible, mirror, formatFn, formatType, formatOptions) => {
280
289
  if (!isVisible && !isMirror) return;
281
290
  const rawInput = isMirror ? mirror.value : visible.value;
282
291
  const inputType = readInputType(event);
283
- const { formatted, raw } = formatFn(rawInput, formatType, formatOptions);
292
+ const sourceOptions = isMirror && shouldForceAutoInterpretation(formatOptions) ? buildAutoInterpretedOptions(formatOptions) : formatOptions;
293
+ const { formatted, raw } = formatFn(rawInput, formatType, sourceOptions);
284
294
  if (!formatted && !raw) {
285
295
  visible.value = "";
286
296
  if (mirror.value !== "") mirror.value = "";
@@ -292,6 +302,12 @@ var buildHandler = (visible, mirror, formatFn, formatType, formatOptions) => {
292
302
  };
293
303
  var applyFormat = async (state2, helpers, config) => {
294
304
  if (!state2.element || !state2.api) return;
305
+ if (Array.isArray(config.field) && config.displayField) {
306
+ console.error(
307
+ "[samline/forms] `displayField` can only be used when `field` is a string."
308
+ );
309
+ return;
310
+ }
295
311
  const formatType = config.type;
296
312
  const formatOptions = config.options;
297
313
  const fieldNames = resolveFieldNames(config);
@@ -329,6 +345,8 @@ var applyFormat = async (state2, helpers, config) => {
329
345
  visible,
330
346
  mirror,
331
347
  mirrorIsOwned,
348
+ defaultVisible: null,
349
+ defaultRaw: null,
332
350
  handler: UNBOUND
333
351
  };
334
352
  bucket.set(fieldName, entry);
@@ -341,6 +359,10 @@ var applyFormat = async (state2, helpers, config) => {
341
359
  }
342
360
  }
343
361
  const formatter = await loadFormatter();
362
+ if (state2.isDestroyed) {
363
+ rollbackPhase1(phase1Entries, bucket, state2.formattedFields);
364
+ return;
365
+ }
344
366
  if (!formatter) {
345
367
  rollbackPhase1(phase1Entries, bucket, state2.formattedFields);
346
368
  return;
@@ -365,11 +387,22 @@ var applyFormat = async (state2, helpers, config) => {
365
387
  entry.handler = handler;
366
388
  const initial = entry.visible.value;
367
389
  if (initial !== "") {
368
- const { formatted, raw } = formatter.format(initial, formatType, formatOptions);
390
+ const initialOptions = shouldForceAutoInterpretation(formatOptions) ? buildAutoInterpretedOptions(formatOptions) : formatOptions;
391
+ const { formatted, raw } = formatter.format(initial, formatType, initialOptions);
369
392
  applyFormattedValue(entry.visible, entry.mirror, formatted, raw);
370
393
  } else if (entry.mirrorIsOwned && entry.mirror.value !== "") {
371
394
  entry.mirror.value = "";
372
395
  }
396
+ entry.defaultVisible = entry.visible.value;
397
+ entry.defaultRaw = entry.mirror.value;
398
+ }
399
+ };
400
+ var resetFormattedFields = (state2) => {
401
+ const bucket = registry.get(state2);
402
+ if (!bucket) return;
403
+ for (const entry of bucket.values()) {
404
+ if (entry.defaultVisible !== null) entry.visible.value = entry.defaultVisible;
405
+ if (entry.defaultRaw !== null) entry.mirror.value = entry.defaultRaw;
373
406
  }
374
407
  };
375
408
  var rollbackPhase1 = (entries, bucket, registry2) => {
@@ -476,19 +509,24 @@ var createDisableAutoSubmit = (state2, helpers) => () => {
476
509
  // src/core/serialize.ts
477
510
  var isEmptyFile = (value) => value instanceof File && value.size === 0 && value.name === "";
478
511
  var appendValue = (data, key, value) => {
479
- const current = data[key];
480
- if (current === void 0) {
481
- data[key] = value;
512
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
513
+ Object.defineProperty(data, key, {
514
+ value,
515
+ writable: true,
516
+ enumerable: true,
517
+ configurable: true
518
+ });
482
519
  return;
483
520
  }
521
+ const current = data[key];
484
522
  if (Array.isArray(current)) {
485
523
  current.push(value);
486
524
  return;
487
525
  }
488
526
  data[key] = [current, value];
489
527
  };
490
- var parseFormData = (formElement) => {
491
- const raw = new FormData(formElement);
528
+ var parseFormData = (formElement, submitter) => {
529
+ const raw = submitter ? new FormData(formElement, submitter) : new FormData(formElement);
492
530
  const formData = new FormData();
493
531
  const data = {};
494
532
  raw.forEach((value, key) => {
@@ -570,8 +608,8 @@ var resolveFormElement = (target) => {
570
608
  }
571
609
  return null;
572
610
  };
573
- var getNamedFields = (form2) => Array.from(form2.querySelectorAll("input[name], select[name], textarea[name]")).filter(
574
- isFormFieldElement
611
+ var getNamedFields = (form2) => Array.from(form2.elements).filter(
612
+ (element) => isFormFieldElement(element) && Boolean(element.name)
575
613
  );
576
614
  var queryNamedFields = (form2, name) => getNamedFields(form2).filter((field) => field.name === name);
577
615
  var isFieldElement = (value) => isFormFieldElement(value);
@@ -618,6 +656,9 @@ var readFieldValue = (fields) => {
618
656
  }
619
657
  return fields.map((f) => f.value);
620
658
  }
659
+ if (first instanceof HTMLSelectElement && first.multiple) {
660
+ return Array.from(first.selectedOptions, (option) => option.value);
661
+ }
621
662
  if (first instanceof HTMLSelectElement || first instanceof HTMLTextAreaElement) {
622
663
  return first.value;
623
664
  }
@@ -667,6 +708,13 @@ var writeFieldValue = (fields, value) => {
667
708
  field.value = normalizedValue;
668
709
  continue;
669
710
  }
711
+ if (field instanceof HTMLSelectElement && field.multiple) {
712
+ const selectedValues = normalizedArray ?? [normalizedValue];
713
+ for (const option of field.options) {
714
+ option.selected = selectedValues.includes(option.value);
715
+ }
716
+ continue;
717
+ }
670
718
  if (distributeArray && normalizedArray) {
671
719
  field.value = normalizedArray[index] ?? "";
672
720
  continue;
@@ -676,7 +724,7 @@ var writeFieldValue = (fields, value) => {
676
724
  };
677
725
  var clearAttributes = (form2, attributes) => {
678
726
  for (const attribute of attributes) {
679
- form2.querySelectorAll(`[${attribute}]`).forEach((node) => node.removeAttribute(attribute));
727
+ getNamedFields(form2).forEach((field) => field.removeAttribute(attribute));
680
728
  }
681
729
  };
682
730
  var submitForm = (form2) => {
@@ -735,9 +783,14 @@ var createPrefill = (state2, _helpers) => (fieldName) => {
735
783
  var createReset = (state2, helpers) => () => {
736
784
  if (!state2.element) return state2.api;
737
785
  state2.element.reset();
786
+ resetFormattedFields(state2);
738
787
  state2.manualErrors = {};
739
788
  state2.validationErrors = {};
740
- clearAttributes(state2.element, [state2.attributes.error, state2.attributes.filled]);
789
+ clearAttributes(state2.element, [
790
+ state2.attributes.error,
791
+ state2.attributes.filled,
792
+ "aria-invalid"
793
+ ]);
741
794
  if (state2.isValidated) helpers.syncVisualState();
742
795
  helpers.notifySubscribers();
743
796
  return state2.api;
@@ -778,8 +831,7 @@ var createSetValue = (state2, helpers) => (name, value) => {
778
831
  writeFieldValue(fields, value);
779
832
  const firstField = fields[0];
780
833
  if (!firstField) return state2.api;
781
- const eventType = firstField instanceof HTMLSelectElement || firstField instanceof HTMLInputElement && (firstField.type === "checkbox" || firstField.type === "radio") ? "change" : "input";
782
- firstField.dispatchEvent(new Event(eventType, { bubbles: true }));
834
+ firstField.dispatchEvent(new Event("input", { bubbles: true }));
783
835
  return state2.api;
784
836
  };
785
837
 
@@ -844,6 +896,12 @@ var toPatternTarget = (value) => {
844
896
  }
845
897
  return "";
846
898
  };
899
+ var valuesAreEqual = (left, right) => {
900
+ if (Array.isArray(left) && Array.isArray(right)) {
901
+ return left.length === right.length && left.every((entry, index) => entry === right[index]);
902
+ }
903
+ return left === right;
904
+ };
847
905
  var validateFieldValue = (field, value, rules, values) => {
848
906
  const errors = [];
849
907
  const context = { field, value, values };
@@ -851,6 +909,7 @@ var validateFieldValue = (field, value, rules, values) => {
851
909
  const minLength = resolveRule(rules.minLength);
852
910
  const maxLength = resolveRule(rules.maxLength);
853
911
  const pattern = resolveRule(rules.pattern);
912
+ const sameAs = resolveRule(rules.sameAs);
854
913
  if (required.value && !hasValue(value)) {
855
914
  errors.push(required.message ?? "This field is required.");
856
915
  }
@@ -860,8 +919,19 @@ var validateFieldValue = (field, value, rules, values) => {
860
919
  if (maxLength.value !== void 0 && getValueLength(value) > maxLength.value) {
861
920
  errors.push(maxLength.message ?? `Maximum length is ${maxLength.value}.`);
862
921
  }
863
- if (pattern.value && hasValue(value) && !pattern.value.test(toPatternTarget(value))) {
864
- errors.push(pattern.message ?? "Value does not match the required pattern.");
922
+ if (pattern.value && hasValue(value)) {
923
+ pattern.value.lastIndex = 0;
924
+ const matches = pattern.value.test(toPatternTarget(value));
925
+ pattern.value.lastIndex = 0;
926
+ if (!matches) {
927
+ errors.push(pattern.message ?? "Value does not match the required pattern.");
928
+ }
929
+ }
930
+ if (sameAs.value !== void 0) {
931
+ const otherValue = values[sameAs.value];
932
+ if (hasValue(value) && hasValue(otherValue) && !valuesAreEqual(value, otherValue)) {
933
+ errors.push(sameAs.message ?? `Value must match ${sameAs.value}.`);
934
+ }
865
935
  }
866
936
  const custom = rules.validate ? Array.isArray(rules.validate) ? rules.validate : [rules.validate] : [];
867
937
  for (const validator of custom) {
@@ -923,6 +993,15 @@ var createFormController = (target, options = {}) => {
923
993
  formattedFields: /* @__PURE__ */ new Map(),
924
994
  api: null
925
995
  };
996
+ const validationDependents = /* @__PURE__ */ new Map();
997
+ for (const [field, rules] of Object.entries(state2.validators)) {
998
+ const sameAs = rules.sameAs;
999
+ if (sameAs === void 0) continue;
1000
+ const source = typeof sameAs === "string" ? sameAs : sameAs.value;
1001
+ const dependents = validationDependents.get(source) ?? /* @__PURE__ */ new Set();
1002
+ dependents.add(field);
1003
+ validationDependents.set(source, dependents);
1004
+ }
926
1005
  const notifySubscribers = () => {
927
1006
  if (!state2.api) return;
928
1007
  const snapshot = state2.api.getState();
@@ -941,10 +1020,9 @@ var createFormController = (target, options = {}) => {
941
1020
  const clearFieldCache = () => state2.fieldCache.clear();
942
1021
  const getFieldsByName = (name) => {
943
1022
  if (!state2.element) return [];
944
- if (!state2.fieldCache.has(name)) {
945
- state2.fieldCache.set(name, queryNamedFields(state2.element, name));
946
- }
947
- return state2.fieldCache.get(name) ?? [];
1023
+ const fields = queryNamedFields(state2.element, name);
1024
+ state2.fieldCache.set(name, fields);
1025
+ return fields;
948
1026
  };
949
1027
  const getTrackedFieldNames = () => {
950
1028
  if (!state2.element) return Object.keys(state2.validators);
@@ -980,6 +1058,8 @@ var createFormController = (target, options = {}) => {
980
1058
  for (const field of fields) {
981
1059
  applyBooleanAttribute(field, attributes.filled, isFieldFilled(field));
982
1060
  applyBooleanAttribute(field, attributes.error, hasError);
1061
+ if (hasError) field.setAttribute("aria-invalid", "true");
1062
+ else field.removeAttribute("aria-invalid");
983
1063
  }
984
1064
  }
985
1065
  };
@@ -1006,6 +1086,21 @@ var createFormController = (target, options = {}) => {
1006
1086
  errors: cloneErrors(getMergedErrors())
1007
1087
  };
1008
1088
  };
1089
+ const getAffectedValidationNames = (name) => {
1090
+ const affected = [];
1091
+ const pending = [name];
1092
+ const visited = /* @__PURE__ */ new Set();
1093
+ for (let index = 0; index < pending.length; index += 1) {
1094
+ const current = pending[index];
1095
+ if (visited.has(current)) continue;
1096
+ visited.add(current);
1097
+ affected.push(current);
1098
+ for (const dependent of validationDependents.get(current) ?? []) {
1099
+ if (!visited.has(dependent)) pending.push(dependent);
1100
+ }
1101
+ }
1102
+ return affected;
1103
+ };
1009
1104
  const emitFieldWatchers = (name) => {
1010
1105
  if (!state2.element || !state2.api) return;
1011
1106
  const callbacks = state2.watchedFields.get(name);
@@ -1051,14 +1146,21 @@ var createFormController = (target, options = {}) => {
1051
1146
  delete state2.manualErrors[canonical];
1052
1147
  }
1053
1148
  syncVisualState([displayName]);
1054
- if (state2.isValidated && state2.validators[canonical]) {
1055
- validateNames([canonical]);
1149
+ const affectedValidationNames = getAffectedValidationNames(canonical);
1150
+ if (state2.isValidated && affectedValidationNames.some((field) => state2.validators[field] !== void 0)) {
1151
+ validateNames(affectedValidationNames);
1056
1152
  }
1057
1153
  emitFieldWatchers(canonical);
1058
1154
  if (canonical !== name) emitFieldWatchers(name);
1059
1155
  notifySubscribers();
1060
1156
  scheduleAutoSubmit();
1061
1157
  };
1158
+ const handleExternalDelegatedEvent = (event) => {
1159
+ const target2 = event.target;
1160
+ if (!(target2 instanceof Element) || !isFieldElement(target2)) return;
1161
+ if (target2.form !== state2.element || state2.element?.contains(target2)) return;
1162
+ handleDelegatedEvent(event);
1163
+ };
1062
1164
  const handleSubmitEvent = (event) => {
1063
1165
  if (!state2.element || state2.isDestroyed || !state2.api) return;
1064
1166
  if (normalizedOptions.clearErrorsOnSubmit) state2.manualErrors = {};
@@ -1068,8 +1170,23 @@ var createFormController = (target, options = {}) => {
1068
1170
  if (shouldPrevent) event.preventDefault();
1069
1171
  state2.submitCount += 1;
1070
1172
  notifySubscribers();
1071
- if (!validation.isValid) return;
1072
- const { data, formData } = state2.api.getData();
1173
+ if (!validation.isValid) {
1174
+ for (const name of Object.keys(validation.errors)) {
1175
+ const displayName = resolveDisplayNameForName(state2, name);
1176
+ const field = getFieldsByName(displayName).find((candidate2) => {
1177
+ if (!candidate2.isConnected || candidate2.disabled || candidate2.hidden) return false;
1178
+ return !(candidate2 instanceof HTMLInputElement && candidate2.type === "hidden");
1179
+ });
1180
+ if (field) {
1181
+ field.focus();
1182
+ break;
1183
+ }
1184
+ }
1185
+ return;
1186
+ }
1187
+ const candidate = event;
1188
+ const submitter = candidate.submitter instanceof HTMLElement ? candidate.submitter : null;
1189
+ const { data, formData } = parseFormData(state2.element, submitter);
1073
1190
  const snapshot = state2.api.getState();
1074
1191
  handlers.forEach(
1075
1192
  (handler) => handler.callback(state2.element, data, formData, snapshot)
@@ -1128,7 +1245,7 @@ var createFormController = (target, options = {}) => {
1128
1245
  state2.api = api;
1129
1246
  if (state2.element) {
1130
1247
  addListener2(state2.element, "input", handleDelegatedEvent);
1131
- addListener2(state2.element, "change", handleDelegatedEvent);
1248
+ addListener2(document, "input", handleExternalDelegatedEvent);
1132
1249
  addListener2(state2.element, "submit", handleSubmitEvent);
1133
1250
  startMutationObserver();
1134
1251
  }
@@ -1158,6 +1275,7 @@ var newForm = (input) => {
1158
1275
  console.error("Form ID is required");
1159
1276
  return;
1160
1277
  }
1278
+ available[id]?.destroy();
1161
1279
  const controller = form(id, { ...options });
1162
1280
  available[id] = controller;
1163
1281
  return controller;