@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.
@@ -240,12 +240,37 @@ var restoreCursor = (field, position) => {
240
240
  var state = null;
241
241
  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).";
242
242
  var warned = false;
243
+ var phonePromise = null;
244
+ 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.";
243
245
  var warnOnce = () => {
244
246
  if (warned) return;
245
247
  warned = true;
246
248
  console.error(MISSING_MESSAGE);
247
249
  };
248
- var loadFormatter = () => {
250
+ var ensurePhone = (formatter) => {
251
+ if (typeof formatter.isPhoneRegistered !== "function") return Promise.resolve(true);
252
+ if (formatter.isPhoneRegistered()) return Promise.resolve(true);
253
+ if (!phonePromise) {
254
+ phonePromise = (async () => {
255
+ try {
256
+ const mod = await import("@samline/formatter/phone");
257
+ mod.registerPhone();
258
+ return true;
259
+ } catch {
260
+ console.error(PHONE_MESSAGE);
261
+ phonePromise = null;
262
+ return false;
263
+ }
264
+ })();
265
+ }
266
+ return phonePromise;
267
+ };
268
+ var loadFormatter = async (options = {}) => {
269
+ const formatter = await loadFormatterModule();
270
+ if (!formatter || !options.phone) return formatter;
271
+ return await ensurePhone(formatter) ? formatter : null;
272
+ };
273
+ var loadFormatterModule = () => {
249
274
  if (state && state.status === "resolved") {
250
275
  if (!state.module) warnOnce();
251
276
  return Promise.resolve(state.module);
@@ -380,7 +405,7 @@ var applyFormat = async (state2, helpers, config) => {
380
405
  phase1Entries.push(entry);
381
406
  }
382
407
  }
383
- const formatter = await loadFormatter();
408
+ const formatter = await loadFormatter({ phone: formatType === "phone" });
384
409
  if (state2.isDestroyed) {
385
410
  rollbackPhase1(phase1Entries, bucket, state2.formattedFields);
386
411
  return;
@@ -519,6 +544,7 @@ var createDestroy = (state2, _helpers) => () => {
519
544
  }
520
545
  }
521
546
  state2.watchedFields.clear();
547
+ state2.groupWatchers.clear();
522
548
  state2.subscribers.clear();
523
549
  state2.submitHandlers.clear();
524
550
  state2.fieldCache.clear();
@@ -746,9 +772,9 @@ var writeFieldValue = (fields, value) => {
746
772
  continue;
747
773
  }
748
774
  if (field instanceof HTMLSelectElement && field.multiple) {
749
- const selectedValues = normalizedArray ?? [normalizedValue];
775
+ const selectedValues2 = normalizedArray ?? [normalizedValue];
750
776
  for (const option of field.options) {
751
- option.selected = selectedValues.includes(option.value);
777
+ option.selected = selectedValues2.includes(option.value);
752
778
  }
753
779
  continue;
754
780
  }
@@ -783,8 +809,7 @@ var submitForm = (form3) => {
783
809
  var createGetValue = (state2, helpers) => (name) => readFieldValue(helpers.getFieldsByName(name));
784
810
 
785
811
  // src/api/observe.ts
786
- var createObserve = (state2, helpers) => (field, callback) => {
787
- if (state2.isDestroyed) return () => void 0;
812
+ var observeField = (state2, field, callback) => {
788
813
  const callbacks = state2.watchedFields.get(field) ?? /* @__PURE__ */ new Set();
789
814
  callbacks.add(callback);
790
815
  state2.watchedFields.set(field, callbacks);
@@ -799,6 +824,26 @@ var createObserve = (state2, helpers) => (field, callback) => {
799
824
  }
800
825
  };
801
826
  };
827
+ var observeFields = (state2, fields, callback) => {
828
+ const names = Array.from(new Set(fields));
829
+ if (names.length === 0) return () => void 0;
830
+ const entry = { fields: names, callback };
831
+ state2.groupWatchers.add(entry);
832
+ if (state2.element) {
833
+ const values = Object.fromEntries(names.map((name) => [name, state2.api.getValue(name)]));
834
+ callback(values, null, state2.element, state2.api.getState());
835
+ }
836
+ return () => {
837
+ state2.groupWatchers.delete(entry);
838
+ };
839
+ };
840
+ var createObserve = (state2, _helpers) => {
841
+ function observe(field, callback) {
842
+ if (state2.isDestroyed) return () => void 0;
843
+ return Array.isArray(field) ? observeFields(state2, field, callback) : observeField(state2, field, callback);
844
+ }
845
+ return observe;
846
+ };
802
847
 
803
848
  // src/api/on-submit.ts
804
849
  var createOnSubmit = (state2, _helpers) => (callback, preventDefault = true) => {
@@ -870,6 +915,75 @@ var createSetErrors = (state2, helpers) => (fields) => {
870
915
  return state2.api;
871
916
  };
872
917
 
918
+ // src/api/set-options.ts
919
+ var isGroup = (entry) => typeof entry === "object" && entry !== null && Array.isArray(entry.options);
920
+ var createOption = (entry) => {
921
+ const option = document.createElement("option");
922
+ if (typeof entry === "object") {
923
+ option.value = String(entry.value);
924
+ option.textContent = entry.label ?? String(entry.value);
925
+ option.disabled = Boolean(entry.disabled);
926
+ if (entry.selected) option.defaultSelected = true;
927
+ } else {
928
+ option.value = String(entry);
929
+ option.textContent = String(entry);
930
+ }
931
+ return option;
932
+ };
933
+ var buildNodes = (options) => {
934
+ const nodes = [];
935
+ let hasExplicitSelection = false;
936
+ const track = (option) => {
937
+ if (option.defaultSelected) hasExplicitSelection = true;
938
+ return option;
939
+ };
940
+ for (const entry of options) {
941
+ if (entry === null || entry === void 0) continue;
942
+ if (isGroup(entry)) {
943
+ const group = document.createElement("optgroup");
944
+ group.label = entry.label;
945
+ group.disabled = Boolean(entry.disabled);
946
+ for (const child of entry.options) {
947
+ if (child === null || child === void 0) continue;
948
+ group.appendChild(track(createOption(child)));
949
+ }
950
+ nodes.push(group);
951
+ continue;
952
+ }
953
+ nodes.push(track(createOption(entry)));
954
+ }
955
+ return { nodes, hasExplicitSelection };
956
+ };
957
+ var selectedValues = (select) => Array.from(select.selectedOptions, (option) => option.value);
958
+ var restoreSelection = (select, previous) => {
959
+ if (previous.length === 0) return;
960
+ const options = Array.from(select.options);
961
+ if (select.multiple) {
962
+ for (const option of options) option.selected = previous.includes(option.value);
963
+ return;
964
+ }
965
+ const match = options.find((option) => option.value === previous[0]);
966
+ if (match) match.selected = true;
967
+ };
968
+ var sameValues = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
969
+ var createSetOptions = (state2, helpers) => (name, options, config = {}) => {
970
+ if (!state2.element || state2.isDestroyed) return state2.api;
971
+ const selects = helpers.getFieldsByName(name).filter((field) => field instanceof HTMLSelectElement);
972
+ if (selects.length === 0) return state2.api;
973
+ const keepValue = config.keepValue ?? true;
974
+ const changed = [];
975
+ for (const select of selects) {
976
+ const previous = selectedValues(select);
977
+ const { nodes, hasExplicitSelection } = buildNodes(options);
978
+ select.replaceChildren(...nodes);
979
+ if (keepValue && !hasExplicitSelection) restoreSelection(select, previous);
980
+ if (!sameValues(previous, selectedValues(select))) changed.push(select);
981
+ }
982
+ helpers.commitOwnMutations(selects);
983
+ changed[0]?.dispatchEvent(new Event("input", { bubbles: true }));
984
+ return state2.api;
985
+ };
986
+
873
987
  // src/api/set-value.ts
874
988
  var createSetValue = (state2, helpers) => (name, value) => {
875
989
  if (state2.isDestroyed) return state2.api;
@@ -893,28 +1007,46 @@ var createSubscribe = (state2, _helpers) => (listener) => {
893
1007
  };
894
1008
 
895
1009
  // src/api/unwatch.ts
896
- var createUnwatch = (state2, _helpers) => (field, callback) => {
897
- if (!field) {
898
- state2.watchedFields.clear();
899
- return state2.api;
900
- }
901
- if (!callback) {
902
- state2.watchedFields.delete(field);
1010
+ var sameFields = (left, right) => left.length === right.size && left.every((name) => right.has(name));
1011
+ var createUnwatch = (state2, _helpers) => {
1012
+ function unwatch(field, callback) {
1013
+ if (field === void 0 || field === "") {
1014
+ state2.watchedFields.clear();
1015
+ state2.groupWatchers.clear();
1016
+ return state2.api;
1017
+ }
1018
+ if (Array.isArray(field)) {
1019
+ const names = new Set(field);
1020
+ for (const entry of Array.from(state2.groupWatchers)) {
1021
+ if (!sameFields(entry.fields, names)) continue;
1022
+ if (callback && entry.callback !== callback) continue;
1023
+ state2.groupWatchers.delete(entry);
1024
+ }
1025
+ return state2.api;
1026
+ }
1027
+ if (!callback) {
1028
+ state2.watchedFields.delete(field);
1029
+ return state2.api;
1030
+ }
1031
+ const callbacks = state2.watchedFields.get(field);
1032
+ callbacks?.delete(callback);
1033
+ if (callbacks && callbacks.size === 0) {
1034
+ state2.watchedFields.delete(field);
1035
+ }
903
1036
  return state2.api;
904
1037
  }
905
- const callbacks = state2.watchedFields.get(field);
906
- callbacks?.delete(callback);
907
- if (callbacks && callbacks.size === 0) {
908
- state2.watchedFields.delete(field);
909
- }
910
- return state2.api;
1038
+ return unwatch;
911
1039
  };
912
1040
 
913
1041
  // src/api/watch.ts
914
- var createWatch = (state2, helpers) => (field, callback) => {
1042
+ var createWatch = (state2, helpers) => {
915
1043
  const observe = createObserve(state2, helpers);
916
- observe(field, callback);
917
- return state2.api;
1044
+ function watch(field, callback) {
1045
+ if (Array.isArray(field)) observe(field, callback);
1046
+ else observe(field, callback);
1047
+ return state2.api;
1048
+ }
1049
+ return watch;
918
1050
  };
919
1051
 
920
1052
  // src/core/validation.ts
@@ -1067,6 +1199,7 @@ var createFormController = (target, options = {}) => {
1067
1199
  attributes,
1068
1200
  validators: normalizedOptions.validators ?? {},
1069
1201
  watchedFields: /* @__PURE__ */ new Map(),
1202
+ groupWatchers: /* @__PURE__ */ new Set(),
1070
1203
  subscribers: /* @__PURE__ */ new Set(),
1071
1204
  submitHandlers: /* @__PURE__ */ new Set(),
1072
1205
  fieldCache: /* @__PURE__ */ new Map(),
@@ -1237,7 +1370,7 @@ var createFormController = (target, options = {}) => {
1237
1370
  }
1238
1371
  return affected;
1239
1372
  };
1240
- const emitFieldWatchers = (name) => {
1373
+ const emitSingleWatchers = (name) => {
1241
1374
  if (!state2.element || !state2.api) return;
1242
1375
  const callbacks = state2.watchedFields.get(name);
1243
1376
  if (!callbacks || callbacks.size === 0) return;
@@ -1246,6 +1379,23 @@ var createFormController = (target, options = {}) => {
1246
1379
  const snapshot = state2.api.getState();
1247
1380
  callbacks.forEach((callback) => callback(value, fieldElement, state2.element, snapshot));
1248
1381
  };
1382
+ const emitGroupWatchers = (names) => {
1383
+ if (!state2.element || !state2.api || state2.groupWatchers.size === 0) return;
1384
+ let snapshot;
1385
+ for (const entry of Array.from(state2.groupWatchers)) {
1386
+ const changed = names ? names.find((name) => entry.fields.includes(name)) : null;
1387
+ if (changed === void 0) continue;
1388
+ snapshot ?? (snapshot = state2.api.getState());
1389
+ const values = Object.fromEntries(
1390
+ entry.fields.map((field) => [field, state2.api.getValue(field)])
1391
+ );
1392
+ entry.callback(values, changed, state2.element, snapshot);
1393
+ }
1394
+ };
1395
+ const emitFieldWatchers = (name) => {
1396
+ emitSingleWatchers(name);
1397
+ emitGroupWatchers([name]);
1398
+ };
1249
1399
  const runAutoSubmit = () => {
1250
1400
  state2.autoSubmitTimer = null;
1251
1401
  if (!state2.element || state2.isDestroyed || !state2.autoSubmitEnabled) return;
@@ -1266,8 +1416,20 @@ var createFormController = (target, options = {}) => {
1266
1416
  }
1267
1417
  runAutoSubmit();
1268
1418
  };
1419
+ const selectSnapshots = /* @__PURE__ */ new WeakMap();
1420
+ const readSelectSnapshot = (select) => Array.from(select.selectedOptions, (option) => option.value).join("\0");
1421
+ const snapshotSelect = (select) => {
1422
+ selectSnapshots.set(select, readSelectSnapshot(select));
1423
+ };
1424
+ const snapshotAllSelects = () => {
1425
+ if (!state2.element) return;
1426
+ for (const field of getNamedFields(state2.element)) {
1427
+ if (field instanceof HTMLSelectElement) snapshotSelect(field);
1428
+ }
1429
+ };
1269
1430
  const emitAllFieldWatchers = () => {
1270
- for (const name of Array.from(state2.watchedFields.keys())) emitFieldWatchers(name);
1431
+ for (const name of Array.from(state2.watchedFields.keys())) emitSingleWatchers(name);
1432
+ emitGroupWatchers(null);
1271
1433
  };
1272
1434
  const applyReset = () => {
1273
1435
  if (!state2.element || state2.isDestroyed) return;
@@ -1279,6 +1441,7 @@ var createFormController = (target, options = {}) => {
1279
1441
  state2.validationElementErrors.clear();
1280
1442
  clearAttributes(state2.element, [attributes.error, attributes.filled, "aria-invalid"]);
1281
1443
  syncVisualState();
1444
+ snapshotAllSelects();
1282
1445
  emitAllFieldWatchers();
1283
1446
  notifySubscribers();
1284
1447
  };
@@ -1293,7 +1456,8 @@ var createFormController = (target, options = {}) => {
1293
1456
  validateNames,
1294
1457
  emitFieldWatchers,
1295
1458
  scheduleAutoSubmit,
1296
- applyReset
1459
+ applyReset,
1460
+ commitOwnMutations: (selects) => commitOwnMutations(selects)
1297
1461
  };
1298
1462
  const handleDelegatedEvent = (event) => {
1299
1463
  const target2 = event.target;
@@ -1301,6 +1465,7 @@ var createFormController = (target, options = {}) => {
1301
1465
  const name = target2.name;
1302
1466
  if (!name) return;
1303
1467
  clearFieldCache();
1468
+ if (target2 instanceof HTMLSelectElement) snapshotSelect(target2);
1304
1469
  const canonical = resolveCanonicalForName(state2, name) ?? name;
1305
1470
  const displayName = resolveDisplayNameForName(state2, name);
1306
1471
  if (normalizedOptions.clearManualErrorsOnChange) {
@@ -1311,8 +1476,9 @@ var createFormController = (target, options = {}) => {
1311
1476
  if (state2.isValidated && affectedValidationNames.some((field) => state2.validators[field] !== void 0)) {
1312
1477
  validateNames(affectedValidationNames);
1313
1478
  }
1314
- emitFieldWatchers(canonical);
1315
- if (canonical !== name) emitFieldWatchers(name);
1479
+ emitSingleWatchers(canonical);
1480
+ if (canonical !== name) emitSingleWatchers(name);
1481
+ emitGroupWatchers(canonical !== name ? [canonical, name] : [canonical]);
1316
1482
  notifySubscribers();
1317
1483
  scheduleAutoSubmit();
1318
1484
  };
@@ -1341,6 +1507,10 @@ var createFormController = (target, options = {}) => {
1341
1507
  };
1342
1508
  const handleSubmitEvent = (event) => {
1343
1509
  if (!state2.element || state2.isDestroyed || !state2.api) return;
1510
+ if (!state2.isAutoSubmitting && state2.autoSubmitTimer) {
1511
+ clearTimeout(state2.autoSubmitTimer);
1512
+ state2.autoSubmitTimer = null;
1513
+ }
1344
1514
  if (normalizedOptions.clearErrorsOnSubmit) state2.manualErrors = {};
1345
1515
  const validation = state2.api.validate();
1346
1516
  const handlers = Array.from(state2.submitHandlers);
@@ -1393,18 +1563,62 @@ var createFormController = (target, options = {}) => {
1393
1563
  const cancel = () => clearTimeout(timer);
1394
1564
  state2.cleanups.add(cancel);
1395
1565
  };
1566
+ const touchesFields = (node) => node instanceof Element && (isFieldElement(node) || node.querySelector("input, select, textarea") !== null);
1567
+ const isStructural = (record) => record.type === "attributes" || Array.from(record.addedNodes).some(touchesFields) || Array.from(record.removedNodes).some(touchesFields);
1568
+ const isOptionNode = (node) => node instanceof HTMLOptionElement || node instanceof HTMLOptGroupElement;
1569
+ const optionListOwner = (record) => {
1570
+ if (record.type !== "childList") return null;
1571
+ const touchesOptions = Array.from(record.addedNodes).some(isOptionNode) || Array.from(record.removedNodes).some(isOptionNode);
1572
+ if (!touchesOptions) return null;
1573
+ const target2 = record.target;
1574
+ if (target2 instanceof HTMLSelectElement) return target2;
1575
+ if (target2 instanceof HTMLOptGroupElement && target2.parentElement instanceof HTMLSelectElement) {
1576
+ return target2.parentElement;
1577
+ }
1578
+ return null;
1579
+ };
1580
+ const handleMutations = (records) => {
1581
+ if (state2.isDestroyed) return;
1582
+ const structural = records.some(isStructural);
1583
+ const changedNames = /* @__PURE__ */ new Set();
1584
+ for (const record of records) {
1585
+ const select = optionListOwner(record);
1586
+ if (!select || !select.name) continue;
1587
+ const next = readSelectSnapshot(select);
1588
+ if (selectSnapshots.get(select) !== next) changedNames.add(select.name);
1589
+ selectSnapshots.set(select, next);
1590
+ }
1591
+ if (!structural && changedNames.size === 0) return;
1592
+ clearFieldCache();
1593
+ if (structural) {
1594
+ snapshotAllSelects();
1595
+ if (state2.isValidated) validateNames();
1596
+ else syncVisualState();
1597
+ } else {
1598
+ const affected = /* @__PURE__ */ new Set();
1599
+ for (const name of changedNames) {
1600
+ for (const field of getAffectedValidationNames(name)) affected.add(field);
1601
+ }
1602
+ const names = Array.from(affected);
1603
+ syncVisualState(Array.from(changedNames));
1604
+ if (state2.isValidated && names.some((field) => state2.validators[field] !== void 0)) {
1605
+ validateNames(names);
1606
+ }
1607
+ }
1608
+ for (const name of changedNames) emitFieldWatchers(name);
1609
+ notifySubscribers();
1610
+ };
1611
+ const commitOwnMutations = (selects) => {
1612
+ selects.forEach(snapshotSelect);
1613
+ const pending = state2.mutationObserver?.takeRecords() ?? [];
1614
+ const foreign = pending.filter(
1615
+ (record) => !selects.some((select) => select.contains(record.target))
1616
+ );
1617
+ if (foreign.length > 0) handleMutations(foreign);
1618
+ };
1396
1619
  const startMutationObserver = () => {
1397
1620
  if (!state2.element || typeof MutationObserver === "undefined") return;
1398
- const touchesFields = (node) => node instanceof Element && (isFieldElement(node) || node.querySelector("input, select, textarea") !== null);
1399
- const isRelevant = (record) => record.type === "attributes" || Array.from(record.addedNodes).some(touchesFields) || Array.from(record.removedNodes).some(touchesFields);
1400
- state2.mutationObserver = new MutationObserver((records) => {
1401
- if (state2.isDestroyed || !records.some(isRelevant)) return;
1402
- clearFieldCache();
1403
- if (state2.isValidated) {
1404
- validateNames();
1405
- } else syncVisualState();
1406
- notifySubscribers();
1407
- });
1621
+ state2.mutationObserver = new MutationObserver(handleMutations);
1408
1622
  state2.mutationObserver.observe(state2.element, {
1409
1623
  childList: true,
1410
1624
  subtree: true,
@@ -1433,6 +1647,7 @@ var createFormController = (target, options = {}) => {
1433
1647
  setErrors: createSetErrors(state2, helpers),
1434
1648
  clearErrors: createClearErrors(state2, helpers),
1435
1649
  setValue: createSetValue(state2, helpers),
1650
+ setOptions: createSetOptions(state2, helpers),
1436
1651
  getValue: createGetValue(state2, helpers),
1437
1652
  getField: createGetField(state2, helpers),
1438
1653
  validate: createValidate(state2, helpers),
@@ -1452,6 +1667,7 @@ var createFormController = (target, options = {}) => {
1452
1667
  addListener2(document, "input", handleExternalDelegatedEvent);
1453
1668
  addListener2(state2.element, "submit", handleSubmitEvent);
1454
1669
  addListener2(state2.element, "reset", handleResetEvent);
1670
+ snapshotAllSelects();
1455
1671
  startMutationObserver();
1456
1672
  }
1457
1673
  if (normalizedOptions.autoSubmit) {