@samline/forms 2.8.0 → 2.10.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.
package/dist/index.cjs CHANGED
@@ -241,12 +241,37 @@ var restoreCursor = (field, position) => {
241
241
  var state = null;
242
242
  var MISSING_MESSAGE = "[samline/forms] The `format()` and `formatAll()` methods require the @samline/formatter package. Install it with: npm i @samline/formatter (or pnpm/bun/yarn equivalent).";
243
243
  var warned = false;
244
+ var phonePromise = null;
245
+ var PHONE_MESSAGE = "[samline/forms] Phone formatting needs `@samline/formatter/phone` (@samline/formatter >= 3.0.0 makes phones opt-in) and it could not be loaded.";
244
246
  var warnOnce = () => {
245
247
  if (warned) return;
246
248
  warned = true;
247
249
  console.error(MISSING_MESSAGE);
248
250
  };
249
- var loadFormatter = () => {
251
+ var ensurePhone = (formatter) => {
252
+ if (typeof formatter.isPhoneRegistered !== "function") return Promise.resolve(true);
253
+ if (formatter.isPhoneRegistered()) return Promise.resolve(true);
254
+ if (!phonePromise) {
255
+ phonePromise = (async () => {
256
+ try {
257
+ const mod = await import("@samline/formatter/phone");
258
+ mod.registerPhone();
259
+ return true;
260
+ } catch {
261
+ console.error(PHONE_MESSAGE);
262
+ phonePromise = null;
263
+ return false;
264
+ }
265
+ })();
266
+ }
267
+ return phonePromise;
268
+ };
269
+ var loadFormatter = async (options = {}) => {
270
+ const formatter = await loadFormatterModule();
271
+ if (!formatter || !options.phone) return formatter;
272
+ return await ensurePhone(formatter) ? formatter : null;
273
+ };
274
+ var loadFormatterModule = () => {
250
275
  if (state && state.status === "resolved") {
251
276
  if (!state.module) warnOnce();
252
277
  return Promise.resolve(state.module);
@@ -381,7 +406,7 @@ var applyFormat = async (state2, helpers, config) => {
381
406
  phase1Entries.push(entry);
382
407
  }
383
408
  }
384
- const formatter = await loadFormatter();
409
+ const formatter = await loadFormatter({ phone: formatType === "phone" });
385
410
  if (state2.isDestroyed) {
386
411
  rollbackPhase1(phase1Entries, bucket, state2.formattedFields);
387
412
  return;
@@ -520,6 +545,7 @@ var createDestroy = (state2, _helpers) => () => {
520
545
  }
521
546
  }
522
547
  state2.watchedFields.clear();
548
+ state2.groupWatchers.clear();
523
549
  state2.subscribers.clear();
524
550
  state2.submitHandlers.clear();
525
551
  state2.fieldCache.clear();
@@ -747,9 +773,9 @@ var writeFieldValue = (fields, value) => {
747
773
  continue;
748
774
  }
749
775
  if (field instanceof HTMLSelectElement && field.multiple) {
750
- const selectedValues = normalizedArray ?? [normalizedValue];
776
+ const selectedValues2 = normalizedArray ?? [normalizedValue];
751
777
  for (const option of field.options) {
752
- option.selected = selectedValues.includes(option.value);
778
+ option.selected = selectedValues2.includes(option.value);
753
779
  }
754
780
  continue;
755
781
  }
@@ -784,8 +810,7 @@ var submitForm = (form2) => {
784
810
  var createGetValue = (state2, helpers) => (name) => readFieldValue(helpers.getFieldsByName(name));
785
811
 
786
812
  // src/api/observe.ts
787
- var createObserve = (state2, helpers) => (field, callback) => {
788
- if (state2.isDestroyed) return () => void 0;
813
+ var observeField = (state2, field, callback) => {
789
814
  const callbacks = state2.watchedFields.get(field) ?? /* @__PURE__ */ new Set();
790
815
  callbacks.add(callback);
791
816
  state2.watchedFields.set(field, callbacks);
@@ -800,6 +825,26 @@ var createObserve = (state2, helpers) => (field, callback) => {
800
825
  }
801
826
  };
802
827
  };
828
+ var observeFields = (state2, fields, callback) => {
829
+ const names = Array.from(new Set(fields));
830
+ if (names.length === 0) return () => void 0;
831
+ const entry = { fields: names, callback };
832
+ state2.groupWatchers.add(entry);
833
+ if (state2.element) {
834
+ const values = Object.fromEntries(names.map((name) => [name, state2.api.getValue(name)]));
835
+ callback(values, null, state2.element, state2.api.getState());
836
+ }
837
+ return () => {
838
+ state2.groupWatchers.delete(entry);
839
+ };
840
+ };
841
+ var createObserve = (state2, _helpers) => {
842
+ function observe(field, callback) {
843
+ if (state2.isDestroyed) return () => void 0;
844
+ return Array.isArray(field) ? observeFields(state2, field, callback) : observeField(state2, field, callback);
845
+ }
846
+ return observe;
847
+ };
803
848
 
804
849
  // src/api/on-submit.ts
805
850
  var createOnSubmit = (state2, _helpers) => (callback, preventDefault = true) => {
@@ -871,6 +916,75 @@ var createSetErrors = (state2, helpers) => (fields) => {
871
916
  return state2.api;
872
917
  };
873
918
 
919
+ // src/api/set-options.ts
920
+ var isGroup = (entry) => typeof entry === "object" && entry !== null && Array.isArray(entry.options);
921
+ var createOption = (entry) => {
922
+ const option = document.createElement("option");
923
+ if (typeof entry === "object") {
924
+ option.value = String(entry.value);
925
+ option.textContent = entry.label ?? String(entry.value);
926
+ option.disabled = Boolean(entry.disabled);
927
+ if (entry.selected) option.defaultSelected = true;
928
+ } else {
929
+ option.value = String(entry);
930
+ option.textContent = String(entry);
931
+ }
932
+ return option;
933
+ };
934
+ var buildNodes = (options) => {
935
+ const nodes = [];
936
+ let hasExplicitSelection = false;
937
+ const track = (option) => {
938
+ if (option.defaultSelected) hasExplicitSelection = true;
939
+ return option;
940
+ };
941
+ for (const entry of options) {
942
+ if (entry === null || entry === void 0) continue;
943
+ if (isGroup(entry)) {
944
+ const group = document.createElement("optgroup");
945
+ group.label = entry.label;
946
+ group.disabled = Boolean(entry.disabled);
947
+ for (const child of entry.options) {
948
+ if (child === null || child === void 0) continue;
949
+ group.appendChild(track(createOption(child)));
950
+ }
951
+ nodes.push(group);
952
+ continue;
953
+ }
954
+ nodes.push(track(createOption(entry)));
955
+ }
956
+ return { nodes, hasExplicitSelection };
957
+ };
958
+ var selectedValues = (select) => Array.from(select.selectedOptions, (option) => option.value);
959
+ var restoreSelection = (select, previous) => {
960
+ if (previous.length === 0) return;
961
+ const options = Array.from(select.options);
962
+ if (select.multiple) {
963
+ for (const option of options) option.selected = previous.includes(option.value);
964
+ return;
965
+ }
966
+ const match = options.find((option) => option.value === previous[0]);
967
+ if (match) match.selected = true;
968
+ };
969
+ var sameValues = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
970
+ var createSetOptions = (state2, helpers) => (name, options, config = {}) => {
971
+ if (!state2.element || state2.isDestroyed) return state2.api;
972
+ const selects = helpers.getFieldsByName(name).filter((field) => field instanceof HTMLSelectElement);
973
+ if (selects.length === 0) return state2.api;
974
+ const keepValue = config.keepValue ?? true;
975
+ const changed = [];
976
+ for (const select of selects) {
977
+ const previous = selectedValues(select);
978
+ const { nodes, hasExplicitSelection } = buildNodes(options);
979
+ select.replaceChildren(...nodes);
980
+ if (keepValue && !hasExplicitSelection) restoreSelection(select, previous);
981
+ if (!sameValues(previous, selectedValues(select))) changed.push(select);
982
+ }
983
+ helpers.commitOwnMutations(selects);
984
+ changed[0]?.dispatchEvent(new Event("input", { bubbles: true }));
985
+ return state2.api;
986
+ };
987
+
874
988
  // src/api/set-value.ts
875
989
  var createSetValue = (state2, helpers) => (name, value) => {
876
990
  if (state2.isDestroyed) return state2.api;
@@ -894,28 +1008,46 @@ var createSubscribe = (state2, _helpers) => (listener) => {
894
1008
  };
895
1009
 
896
1010
  // src/api/unwatch.ts
897
- var createUnwatch = (state2, _helpers) => (field, callback) => {
898
- if (!field) {
899
- state2.watchedFields.clear();
900
- return state2.api;
901
- }
902
- if (!callback) {
903
- state2.watchedFields.delete(field);
1011
+ var sameFields = (left, right) => left.length === right.size && left.every((name) => right.has(name));
1012
+ var createUnwatch = (state2, _helpers) => {
1013
+ function unwatch(field, callback) {
1014
+ if (field === void 0 || field === "") {
1015
+ state2.watchedFields.clear();
1016
+ state2.groupWatchers.clear();
1017
+ return state2.api;
1018
+ }
1019
+ if (Array.isArray(field)) {
1020
+ const names = new Set(field);
1021
+ for (const entry of Array.from(state2.groupWatchers)) {
1022
+ if (!sameFields(entry.fields, names)) continue;
1023
+ if (callback && entry.callback !== callback) continue;
1024
+ state2.groupWatchers.delete(entry);
1025
+ }
1026
+ return state2.api;
1027
+ }
1028
+ if (!callback) {
1029
+ state2.watchedFields.delete(field);
1030
+ return state2.api;
1031
+ }
1032
+ const callbacks = state2.watchedFields.get(field);
1033
+ callbacks?.delete(callback);
1034
+ if (callbacks && callbacks.size === 0) {
1035
+ state2.watchedFields.delete(field);
1036
+ }
904
1037
  return state2.api;
905
1038
  }
906
- const callbacks = state2.watchedFields.get(field);
907
- callbacks?.delete(callback);
908
- if (callbacks && callbacks.size === 0) {
909
- state2.watchedFields.delete(field);
910
- }
911
- return state2.api;
1039
+ return unwatch;
912
1040
  };
913
1041
 
914
1042
  // src/api/watch.ts
915
- var createWatch = (state2, helpers) => (field, callback) => {
1043
+ var createWatch = (state2, helpers) => {
916
1044
  const observe = createObserve(state2, helpers);
917
- observe(field, callback);
918
- return state2.api;
1045
+ function watch(field, callback) {
1046
+ if (Array.isArray(field)) observe(field, callback);
1047
+ else observe(field, callback);
1048
+ return state2.api;
1049
+ }
1050
+ return watch;
919
1051
  };
920
1052
 
921
1053
  // src/core/validation.ts
@@ -1083,6 +1215,7 @@ var createFormController = (target, options = {}) => {
1083
1215
  attributes,
1084
1216
  validators: normalizedOptions.validators ?? {},
1085
1217
  watchedFields: /* @__PURE__ */ new Map(),
1218
+ groupWatchers: /* @__PURE__ */ new Set(),
1086
1219
  subscribers: /* @__PURE__ */ new Set(),
1087
1220
  submitHandlers: /* @__PURE__ */ new Set(),
1088
1221
  fieldCache: /* @__PURE__ */ new Map(),
@@ -1253,7 +1386,7 @@ var createFormController = (target, options = {}) => {
1253
1386
  }
1254
1387
  return affected;
1255
1388
  };
1256
- const emitFieldWatchers = (name) => {
1389
+ const emitSingleWatchers = (name) => {
1257
1390
  if (!state2.element || !state2.api) return;
1258
1391
  const callbacks = state2.watchedFields.get(name);
1259
1392
  if (!callbacks || callbacks.size === 0) return;
@@ -1262,6 +1395,23 @@ var createFormController = (target, options = {}) => {
1262
1395
  const snapshot = state2.api.getState();
1263
1396
  callbacks.forEach((callback) => callback(value, fieldElement, state2.element, snapshot));
1264
1397
  };
1398
+ const emitGroupWatchers = (names) => {
1399
+ if (!state2.element || !state2.api || state2.groupWatchers.size === 0) return;
1400
+ let snapshot;
1401
+ for (const entry of Array.from(state2.groupWatchers)) {
1402
+ const changed = names ? names.find((name) => entry.fields.includes(name)) : null;
1403
+ if (changed === void 0) continue;
1404
+ snapshot ?? (snapshot = state2.api.getState());
1405
+ const values = Object.fromEntries(
1406
+ entry.fields.map((field) => [field, state2.api.getValue(field)])
1407
+ );
1408
+ entry.callback(values, changed, state2.element, snapshot);
1409
+ }
1410
+ };
1411
+ const emitFieldWatchers = (name) => {
1412
+ emitSingleWatchers(name);
1413
+ emitGroupWatchers([name]);
1414
+ };
1265
1415
  const runAutoSubmit = () => {
1266
1416
  state2.autoSubmitTimer = null;
1267
1417
  if (!state2.element || state2.isDestroyed || !state2.autoSubmitEnabled) return;
@@ -1282,8 +1432,20 @@ var createFormController = (target, options = {}) => {
1282
1432
  }
1283
1433
  runAutoSubmit();
1284
1434
  };
1435
+ const selectSnapshots = /* @__PURE__ */ new WeakMap();
1436
+ const readSelectSnapshot = (select) => Array.from(select.selectedOptions, (option) => option.value).join("\0");
1437
+ const snapshotSelect = (select) => {
1438
+ selectSnapshots.set(select, readSelectSnapshot(select));
1439
+ };
1440
+ const snapshotAllSelects = () => {
1441
+ if (!state2.element) return;
1442
+ for (const field of getNamedFields(state2.element)) {
1443
+ if (field instanceof HTMLSelectElement) snapshotSelect(field);
1444
+ }
1445
+ };
1285
1446
  const emitAllFieldWatchers = () => {
1286
- for (const name of Array.from(state2.watchedFields.keys())) emitFieldWatchers(name);
1447
+ for (const name of Array.from(state2.watchedFields.keys())) emitSingleWatchers(name);
1448
+ emitGroupWatchers(null);
1287
1449
  };
1288
1450
  const applyReset = () => {
1289
1451
  if (!state2.element || state2.isDestroyed) return;
@@ -1295,6 +1457,7 @@ var createFormController = (target, options = {}) => {
1295
1457
  state2.validationElementErrors.clear();
1296
1458
  clearAttributes(state2.element, [attributes.error, attributes.filled, "aria-invalid"]);
1297
1459
  syncVisualState();
1460
+ snapshotAllSelects();
1298
1461
  emitAllFieldWatchers();
1299
1462
  notifySubscribers();
1300
1463
  };
@@ -1309,7 +1472,8 @@ var createFormController = (target, options = {}) => {
1309
1472
  validateNames,
1310
1473
  emitFieldWatchers,
1311
1474
  scheduleAutoSubmit,
1312
- applyReset
1475
+ applyReset,
1476
+ commitOwnMutations: (selects) => commitOwnMutations(selects)
1313
1477
  };
1314
1478
  const handleDelegatedEvent = (event) => {
1315
1479
  const target2 = event.target;
@@ -1317,6 +1481,7 @@ var createFormController = (target, options = {}) => {
1317
1481
  const name = target2.name;
1318
1482
  if (!name) return;
1319
1483
  clearFieldCache();
1484
+ if (target2 instanceof HTMLSelectElement) snapshotSelect(target2);
1320
1485
  const canonical = resolveCanonicalForName(state2, name) ?? name;
1321
1486
  const displayName = resolveDisplayNameForName(state2, name);
1322
1487
  if (normalizedOptions.clearManualErrorsOnChange) {
@@ -1327,8 +1492,9 @@ var createFormController = (target, options = {}) => {
1327
1492
  if (state2.isValidated && affectedValidationNames.some((field) => state2.validators[field] !== void 0)) {
1328
1493
  validateNames(affectedValidationNames);
1329
1494
  }
1330
- emitFieldWatchers(canonical);
1331
- if (canonical !== name) emitFieldWatchers(name);
1495
+ emitSingleWatchers(canonical);
1496
+ if (canonical !== name) emitSingleWatchers(name);
1497
+ emitGroupWatchers(canonical !== name ? [canonical, name] : [canonical]);
1332
1498
  notifySubscribers();
1333
1499
  scheduleAutoSubmit();
1334
1500
  };
@@ -1357,6 +1523,10 @@ var createFormController = (target, options = {}) => {
1357
1523
  };
1358
1524
  const handleSubmitEvent = (event) => {
1359
1525
  if (!state2.element || state2.isDestroyed || !state2.api) return;
1526
+ if (!state2.isAutoSubmitting && state2.autoSubmitTimer) {
1527
+ clearTimeout(state2.autoSubmitTimer);
1528
+ state2.autoSubmitTimer = null;
1529
+ }
1360
1530
  if (normalizedOptions.clearErrorsOnSubmit) state2.manualErrors = {};
1361
1531
  const validation = state2.api.validate();
1362
1532
  const handlers = Array.from(state2.submitHandlers);
@@ -1409,18 +1579,62 @@ var createFormController = (target, options = {}) => {
1409
1579
  const cancel = () => clearTimeout(timer);
1410
1580
  state2.cleanups.add(cancel);
1411
1581
  };
1582
+ const touchesFields = (node) => node instanceof Element && (isFieldElement(node) || node.querySelector("input, select, textarea") !== null);
1583
+ const isStructural = (record) => record.type === "attributes" || Array.from(record.addedNodes).some(touchesFields) || Array.from(record.removedNodes).some(touchesFields);
1584
+ const isOptionNode = (node) => node instanceof HTMLOptionElement || node instanceof HTMLOptGroupElement;
1585
+ const optionListOwner = (record) => {
1586
+ if (record.type !== "childList") return null;
1587
+ const touchesOptions = Array.from(record.addedNodes).some(isOptionNode) || Array.from(record.removedNodes).some(isOptionNode);
1588
+ if (!touchesOptions) return null;
1589
+ const target2 = record.target;
1590
+ if (target2 instanceof HTMLSelectElement) return target2;
1591
+ if (target2 instanceof HTMLOptGroupElement && target2.parentElement instanceof HTMLSelectElement) {
1592
+ return target2.parentElement;
1593
+ }
1594
+ return null;
1595
+ };
1596
+ const handleMutations = (records) => {
1597
+ if (state2.isDestroyed) return;
1598
+ const structural = records.some(isStructural);
1599
+ const changedNames = /* @__PURE__ */ new Set();
1600
+ for (const record of records) {
1601
+ const select = optionListOwner(record);
1602
+ if (!select || !select.name) continue;
1603
+ const next = readSelectSnapshot(select);
1604
+ if (selectSnapshots.get(select) !== next) changedNames.add(select.name);
1605
+ selectSnapshots.set(select, next);
1606
+ }
1607
+ if (!structural && changedNames.size === 0) return;
1608
+ clearFieldCache();
1609
+ if (structural) {
1610
+ snapshotAllSelects();
1611
+ if (state2.isValidated) validateNames();
1612
+ else syncVisualState();
1613
+ } else {
1614
+ const affected = /* @__PURE__ */ new Set();
1615
+ for (const name of changedNames) {
1616
+ for (const field of getAffectedValidationNames(name)) affected.add(field);
1617
+ }
1618
+ const names = Array.from(affected);
1619
+ syncVisualState(Array.from(changedNames));
1620
+ if (state2.isValidated && names.some((field) => state2.validators[field] !== void 0)) {
1621
+ validateNames(names);
1622
+ }
1623
+ }
1624
+ for (const name of changedNames) emitFieldWatchers(name);
1625
+ notifySubscribers();
1626
+ };
1627
+ const commitOwnMutations = (selects) => {
1628
+ selects.forEach(snapshotSelect);
1629
+ const pending = state2.mutationObserver?.takeRecords() ?? [];
1630
+ const foreign = pending.filter(
1631
+ (record) => !selects.some((select) => select.contains(record.target))
1632
+ );
1633
+ if (foreign.length > 0) handleMutations(foreign);
1634
+ };
1412
1635
  const startMutationObserver = () => {
1413
1636
  if (!state2.element || typeof MutationObserver === "undefined") return;
1414
- const touchesFields = (node) => node instanceof Element && (isFieldElement(node) || node.querySelector("input, select, textarea") !== null);
1415
- const isRelevant = (record) => record.type === "attributes" || Array.from(record.addedNodes).some(touchesFields) || Array.from(record.removedNodes).some(touchesFields);
1416
- state2.mutationObserver = new MutationObserver((records) => {
1417
- if (state2.isDestroyed || !records.some(isRelevant)) return;
1418
- clearFieldCache();
1419
- if (state2.isValidated) {
1420
- validateNames();
1421
- } else syncVisualState();
1422
- notifySubscribers();
1423
- });
1637
+ state2.mutationObserver = new MutationObserver(handleMutations);
1424
1638
  state2.mutationObserver.observe(state2.element, {
1425
1639
  childList: true,
1426
1640
  subtree: true,
@@ -1449,6 +1663,7 @@ var createFormController = (target, options = {}) => {
1449
1663
  setErrors: createSetErrors(state2, helpers),
1450
1664
  clearErrors: createClearErrors(state2, helpers),
1451
1665
  setValue: createSetValue(state2, helpers),
1666
+ setOptions: createSetOptions(state2, helpers),
1452
1667
  getValue: createGetValue(state2, helpers),
1453
1668
  getField: createGetField(state2, helpers),
1454
1669
  validate: createValidate(state2, helpers),
@@ -1468,6 +1683,7 @@ var createFormController = (target, options = {}) => {
1468
1683
  addListener2(document, "input", handleExternalDelegatedEvent);
1469
1684
  addListener2(state2.element, "submit", handleSubmitEvent);
1470
1685
  addListener2(state2.element, "reset", handleResetEvent);
1686
+ snapshotAllSelects();
1471
1687
  startMutationObserver();
1472
1688
  }
1473
1689
  if (normalizedOptions.autoSubmit) {