formeo 5.2.0 → 5.2.2

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.
@@ -1,7 +1,7 @@
1
1
 
2
2
  /**
3
3
  formeo - https://formeo.io
4
- Version: 5.1.3
4
+ Version: 5.2.1
5
5
  Author: Draggable https://draggable.io
6
6
  */
7
7
 
@@ -6061,7 +6061,7 @@ if (globalThis !== void 0) globalThis.SmartTooltip = SmartTooltip;
6061
6061
  var name$1, version$2, type, main, module$1, unpkg, exports$1, files, homepage, repository, author, contributors, bugs, description, keywords, ignore, config, scripts, devDependencies, dependencies, release, commitlint, package_default;
6062
6062
  var init_package = __esmMin((() => {
6063
6063
  name$1 = "formeo";
6064
- version$2 = "5.1.3";
6064
+ version$2 = "5.2.1";
6065
6065
  type = "module";
6066
6066
  main = "dist/formeo.cjs.js";
6067
6067
  module$1 = "dist/formeo.es.js";
@@ -6123,7 +6123,7 @@ var init_package = __esmMin((() => {
6123
6123
  "postbuild:demo": "node --no-warnings tools/copy-assets.mjs",
6124
6124
  "build:demo:watch": "vite build --mode demo --watch",
6125
6125
  "build:icons": "node ./tools/generate-sprite",
6126
- "lint": "biome check ./src && node tools/check-color-literals.mjs",
6126
+ "lint": "biome check ./src && node tools/check-color-literals.mjs && node tools/check-doc-links.mjs",
6127
6127
  "lint:fix": "biome check --write ./src",
6128
6128
  "format": "biome format --write .",
6129
6129
  "test": "node --loader=./tools/svg-loader.mjs --import=./tools/__mocks__/sprite-init.mjs --experimental-test-snapshots --require ./tools/test-setup.cjs --test --no-warnings src/**/*.test.{js,mjs}",
@@ -6165,8 +6165,7 @@ var init_package = __esmMin((() => {
6165
6165
  "vite-plugin-banner": "^0.8.0",
6166
6166
  "vite-plugin-compression": "^0.5.1",
6167
6167
  "vite-plugin-html": "^3.2.2",
6168
- "zod": "^4.4.3",
6169
- "zod-to-json-schema": "^3.23.5"
6168
+ "zod": "^4.4.3"
6170
6169
  };
6171
6170
  dependencies = {
6172
6171
  "@draggable/formeo-languages": "^3.4.1",
@@ -12087,7 +12086,7 @@ var init_loaders = __esmMin((() => {
12087
12086
  }));
12088
12087
  //#endregion
12089
12088
  //#region src/lib/js/common/dom.js
12090
- var iconFontTemplates, inputTags, stripOn, useCaptureEvts, defaultActionHandler, getName, DOM, dom;
12089
+ var iconFontTemplates, inputTags, REQUIRED_GROUP_ATTR, OPTION_INPUT_ATTRS, GROUP_CONSUMED_ATTRS, groupWrapperAttrs, stripOn, useCaptureEvts, defaultActionHandler, getName, DOM, dom;
12091
12090
  var init_dom = __esmMin((() => {
12092
12091
  init_components();
12093
12092
  init_constants();
@@ -12109,6 +12108,18 @@ var init_dom = __esmMin((() => {
12109
12108
  "textarea",
12110
12109
  "select"
12111
12110
  ]);
12111
+ REQUIRED_GROUP_ATTR = "formeo-required-group";
12112
+ OPTION_INPUT_ATTRS = ["disabled", "form"];
12113
+ GROUP_CONSUMED_ATTRS = new Set([
12114
+ "type",
12115
+ "id",
12116
+ "name",
12117
+ "className",
12118
+ "value",
12119
+ "required",
12120
+ ...OPTION_INPUT_ATTRS
12121
+ ]);
12122
+ groupWrapperAttrs = (attrs = {}) => Object.fromEntries(Object.entries(attrs).filter(([key]) => !GROUP_CONSUMED_ATTRS.has(key)));
12112
12123
  stripOn = (str) => str.replace(/^on([A-Z])/, (_, l) => l.toLowerCase());
12113
12124
  useCaptureEvts = new Set(["focus", "blur"]);
12114
12125
  defaultActionHandler = (event) => {
@@ -12232,9 +12243,18 @@ var init_dom = __esmMin((() => {
12232
12243
  helpers.forEach(processedOptions, (option) => {
12233
12244
  wrap.children.push(_this.create(option, isPreview));
12234
12245
  });
12235
- if (elem.attrs.className) wrap.className = elem.attrs.className;
12246
+ const groupAttrs = elem.attrs || {};
12247
+ if (groupAttrs.className) wrap.className = groupAttrs.className;
12236
12248
  wrap.id = elem.id;
12237
- wrap.config = { ...elem.config };
12249
+ wrap.attrs = groupWrapperAttrs(groupAttrs);
12250
+ wrap.config = {
12251
+ ...elem.config,
12252
+ required: Boolean(groupAttrs.required)
12253
+ };
12254
+ if (!isPreview && groupAttrs.type === "checkbox" && groupAttrs.required) {
12255
+ wrap.attrs[`data-${REQUIRED_GROUP_ATTR}`] = "true";
12256
+ wrap.action = { change: ({ currentTarget }) => this.syncCheckboxGroupRequired(currentTarget) };
12257
+ }
12238
12258
  return this.create(wrap, isPreview);
12239
12259
  }
12240
12260
  processed.push("options");
@@ -12437,16 +12457,20 @@ var init_dom = __esmMin((() => {
12437
12457
  const { action, attrs = {} } = elem;
12438
12458
  const fieldType = attrs.type || elem.tag;
12439
12459
  const id = attrs.id || elem.id;
12460
+ const name = !isPreview && attrs.name || id;
12461
+ const sharedInputAttrs = Object.fromEntries(OPTION_INPUT_ATTRS.filter((key) => key in attrs).map((key) => [key, attrs[key]]));
12462
+ if (attrs.required) sharedInputAttrs.required = fieldType !== "checkbox" || !options.some(({ selected, checked }) => selected || checked);
12440
12463
  const optionMap = (option, i) => {
12441
12464
  const { label, value, ...rest } = option;
12442
12465
  const defaultInput = () => {
12443
12466
  const input = {
12444
12467
  tag: "input",
12445
12468
  attrs: {
12446
- name: id,
12469
+ name,
12447
12470
  type: fieldType,
12448
12471
  value: value || "",
12449
12472
  id: `${id}-${i}`,
12473
+ ...sharedInputAttrs,
12450
12474
  ...rest
12451
12475
  },
12452
12476
  action
@@ -12548,6 +12572,18 @@ var init_dom = __esmMin((() => {
12548
12572
  const labelAfter = helpers.get(elem, "config.labelAfter");
12549
12573
  return labelAfter === void 0 ? type === "checkbox" || type === "radio" : labelAfter;
12550
12574
  }
12575
+ /**
12576
+ * A required checkbox group needs at least one checked box, not every box.
12577
+ * Every box stays `required` while none is checked; once one is checked none is.
12578
+ * Boxes inside a hidden container are never required.
12579
+ * @param {Element} groupElem wrapper holding the group's checkboxes
12580
+ */
12581
+ syncCheckboxGroupRequired(groupElem) {
12582
+ const boxes = Array.from(groupElem.querySelectorAll("input[type=\"checkbox\"]"));
12583
+ const isHidden = Boolean(groupElem.closest("[hidden]"));
12584
+ const noneChecked = !boxes.some((box) => box.checked);
12585
+ for (const box of boxes) box.required = !isHidden && noneChecked;
12586
+ }
12551
12587
  requiredMark = () => ({
12552
12588
  tag: "span",
12553
12589
  className: "text-error",
@@ -12571,7 +12607,7 @@ var init_dom = __esmMin((() => {
12571
12607
  * @return {Object} config object
12572
12608
  */
12573
12609
  label(elem, fMap) {
12574
- const required = helpers.get(elem, "attrs.required");
12610
+ const required = helpers.get(elem, "attrs.required") || helpers.get(elem, "config.required");
12575
12611
  let { config: { label: labelText = "", helpText = "", tooltip = null } } = elem;
12576
12612
  const { id: elemId, attrs } = elem;
12577
12613
  if (typeof labelText === "function") labelText = labelText();
@@ -18166,12 +18202,23 @@ var assignmentMap = Object.entries(ASSIGNMENT_OPERATORS).reduce((acc, [key, valu
18166
18202
  acc[key] = assignmentHandlers[key];
18167
18203
  return acc;
18168
18204
  }, {});
18205
+ /**
18206
+ * Setting `checked` fires no `change`, so a required checkbox group the box belongs to is
18207
+ * re-synced directly. A synthetic `change` could re-trigger conditions.
18208
+ * @param {Element} elem checkbox or radio input
18209
+ */
18210
+ var syncRequiredGroupOf = (elem) => {
18211
+ const group = elem.closest?.(REQUIRED_GROUP_SELECTOR);
18212
+ if (group) dom.syncCheckboxGroupRequired(group);
18213
+ };
18169
18214
  var targetPropertyMap = {
18170
18215
  isChecked: (elem) => {
18171
18216
  elem.checked = true;
18217
+ syncRequiredGroupOf(elem);
18172
18218
  },
18173
18219
  isNotChecked: (elem) => {
18174
18220
  elem.checked = false;
18221
+ syncRequiredGroupOf(elem);
18175
18222
  },
18176
18223
  value: (elem, { assignment, ...rest }) => {
18177
18224
  const assignmentAction = assignmentMap[assignment]?.(elem, rest);
@@ -18180,15 +18227,60 @@ var targetPropertyMap = {
18180
18227
  return assignmentAction;
18181
18228
  },
18182
18229
  isNotVisible: (elem) => {
18183
- if (elem?._required === void 0) elem._required = elem.required;
18184
18230
  elem.parentElement.setAttribute("hidden", true);
18185
- elem.required = false;
18231
+ suspendRequired(elem);
18186
18232
  },
18187
18233
  isVisible: (elem) => {
18188
18234
  elem.parentElement.removeAttribute("hidden");
18189
- elem.required = elem._required;
18235
+ restoreRequired(elem);
18236
+ }
18237
+ };
18238
+ var FORM_CONTROL_SELECTOR = "input, select, textarea";
18239
+ var REQUIRED_GROUP_SELECTOR = `[data-${REQUIRED_GROUP_ATTR}]`;
18240
+ /**
18241
+ * The element itself when it matches, plus every descendant that does
18242
+ * @param {Element} elem
18243
+ * @param {String} selector
18244
+ * @return {Array<Element>}
18245
+ */
18246
+ var selfAndDescendants = (elem, selector) => [...elem.matches(selector) ? [elem] : [], ...elem.querySelectorAll(selector)];
18247
+ /**
18248
+ * A hidden control can't be filled in, so it must not be required. Remembers each control's
18249
+ * `required` so that restoreRequired can put it back. Works for a field, a group or a whole row.
18250
+ * @param {Element} elem condition target
18251
+ */
18252
+ var suspendRequired = (elem) => {
18253
+ for (const control of selfAndDescendants(elem, FORM_CONTROL_SELECTOR)) {
18254
+ if (control._required === void 0) control._required = control.required;
18255
+ control.required = false;
18256
+ }
18257
+ };
18258
+ /**
18259
+ * Undoes suspendRequired. Controls that were never suspended are left alone, and so are controls
18260
+ * still inside a hidden container: they keep their saved `required` until that container is shown.
18261
+ * @param {Element} elem condition target
18262
+ */
18263
+ var restoreRequired = (elem) => {
18264
+ for (const control of selfAndDescendants(elem, FORM_CONTROL_SELECTOR)) if (control._required !== void 0 && !control.closest("[hidden]")) {
18265
+ control.required = control._required;
18266
+ delete control._required;
18190
18267
  }
18268
+ for (const group of selfAndDescendants(elem, REQUIRED_GROUP_SELECTOR)) dom.syncCheckboxGroupRequired(group);
18191
18269
  };
18270
+ var LOGICAL_AND = new Set(["&&", "and"]);
18271
+ /**
18272
+ * Splits a condition's if-clauses into OR-groups of AND-ed clauses. A clause's `logical`
18273
+ * ('&&' / '||', or 'and' / 'or') joins it to the clause before it, and '&&' binds tighter
18274
+ * than '||' as in JavaScript. The first clause's `logical` is ignored, and a missing
18275
+ * `logical` means OR, which is how every clause behaved before AND was supported.
18276
+ * @param {Array<Object>} ifConditions
18277
+ * @return {Array<Array<Object>>} e.g. A || B && C -> [[A], [B, C]]
18278
+ */
18279
+ var groupIfConditions = (ifConditions = []) => ifConditions.reduce((groups, clause, index) => {
18280
+ if (index > 0 && LOGICAL_AND.has(clause.logical)) groups[groups.length - 1].push(clause);
18281
+ else groups.push([clause]);
18282
+ return groups;
18283
+ }, []);
18192
18284
  //#endregion
18193
18285
  //#region src/lib/js/renderer/index.js
18194
18286
  init_dom();
@@ -18253,20 +18345,31 @@ var FormeoRenderer$1 = class {
18253
18345
  const fieldData = {
18254
18346
  key,
18255
18347
  value,
18256
- label: this.components[baseId(key)]?.config?.label || ""
18348
+ label: this.componentByName(key)?.config?.label || ""
18257
18349
  };
18258
18350
  userFormData.push(fieldData);
18259
18351
  }
18260
18352
  return userFormData;
18261
18353
  }
18354
+ /**
18355
+ * Finds the component data behind a submitted field name
18356
+ * @param {String} name
18357
+ * @return {Object|undefined}
18358
+ */
18359
+ componentByName(name) {
18360
+ return this.components[baseId(name)] || Object.values(this.components).find((component) => component.attrs?.name === name);
18361
+ }
18262
18362
  set userData(data = {}) {
18263
18363
  const form = this.container.querySelector("form");
18264
18364
  for (const key of Object.keys(data)) {
18265
18365
  const fields = form.elements[key];
18266
- if (fields.length && fields[0].type === "checkbox") {
18366
+ const checkables = checkableInputs(fields);
18367
+ if (checkables?.[0].type === "checkbox") {
18267
18368
  const values = Array.isArray(data[key]) ? data[key] : [data[key]];
18268
- for (const field of fields) field.checked = values.includes(field.value);
18269
- } else if (fields.length && fields[0].type === "radio") for (const field of fields) field.checked = field.value === data[key];
18369
+ for (const field of checkables) field.checked = values.includes(field.value);
18370
+ const group = checkables[0].closest(`[data-${REQUIRED_GROUP_ATTR}]`);
18371
+ if (group) dom.syncCheckboxGroupRequired(group);
18372
+ } else if (checkables?.[0].type === "radio") for (const field of checkables) field.checked = field.value === data[key];
18270
18373
  else if (fields.type) fields.value = data[key];
18271
18374
  }
18272
18375
  }
@@ -18292,9 +18395,20 @@ var FormeoRenderer$1 = class {
18292
18395
  children: this.processedData
18293
18396
  };
18294
18397
  this.renderedForm = dom.render(config);
18398
+ this.renderedForm.addEventListener("reset", this.syncRequiredGroupsAfterReset);
18295
18399
  this.applyConditions();
18296
18400
  return this.renderedForm;
18297
18401
  }
18402
+ /**
18403
+ * A reset changes checkedness without firing `change`, so required checkbox groups are re-synced.
18404
+ * The `reset` event fires before the controls revert, hence the deferral.
18405
+ * @param {Event} evt the form's reset event
18406
+ */
18407
+ syncRequiredGroupsAfterReset = ({ currentTarget: form }) => {
18408
+ setTimeout(() => {
18409
+ for (const group of form.querySelectorAll(`[data-${REQUIRED_GROUP_ATTR}]`)) dom.syncCheckboxGroupRequired(group);
18410
+ }, 0);
18411
+ };
18298
18412
  get html() {
18299
18413
  return (this.renderedForm || this.getRenderedForm()).outerHTML;
18300
18414
  }
@@ -18369,7 +18483,8 @@ var FormeoRenderer$1 = class {
18369
18483
  ...attrs,
18370
18484
  "data-clone-of": id
18371
18485
  };
18372
- if (rest.tag === "input") updatedAttrs.name = getName(this.components[componentId]);
18486
+ if (rest.options && ["checkbox", "radio"].includes(attrs.type)) delete updatedAttrs.name;
18487
+ else if (rest.tag === "input") updatedAttrs.name = getName(this.components[componentId]);
18373
18488
  return {
18374
18489
  ...rest,
18375
18490
  id: "f-" + uuid(id),
@@ -18414,46 +18529,57 @@ var FormeoRenderer$1 = class {
18414
18529
  });
18415
18530
  }
18416
18531
  /**
18417
- * Evaulate and execute conditions for fields by creating listeners for input and changes
18418
- * @return {Array} flattened array of conditions
18532
+ * Wires every condition of every rendered component: evaluates it once on render and again
18533
+ * whenever a component one of its if-clauses reads from changes.
18419
18534
  */
18420
- handleComponentCondition = (component, ifRest, thenConditions) => {
18421
- if (!component) return;
18422
- if (isNodeCollection(component)) {
18423
- for (const elem of component) this.handleComponentCondition(elem, ifRest, thenConditions);
18424
- return;
18425
- }
18426
- const listenerEvent = LISTEN_TYPE_MAP(component);
18427
- if (listenerEvent) component.addEventListener(listenerEvent, (evt) => {
18428
- if (this.evaluateCondition(ifRest, evt)) for (const thenCondition of thenConditions) this.execResult(thenCondition, evt);
18429
- }, false);
18430
- const fakeEvt = { target: component };
18431
- if (this.evaluateCondition(ifRest, fakeEvt)) for (const thenCondition of thenConditions) this.execResult(thenCondition, fakeEvt);
18432
- };
18433
18535
  applyConditions = () => {
18434
18536
  for (const { conditions } of Object.values(this.components)) {
18435
18537
  if (!conditions) continue;
18436
- for (const condition of conditions) {
18437
- const { if: ifConditions = [], then: thenConditions = [] } = condition;
18438
- for (const ifCondition of ifConditions) try {
18439
- this.applyCondition(ifCondition, thenConditions);
18440
- } catch (err) {
18441
- console.error("formeo: condition skipped", ifCondition, err);
18442
- }
18538
+ for (const condition of conditions) try {
18539
+ this.applyCondition(condition);
18540
+ } catch (err) {
18541
+ console.error("formeo: condition skipped", condition, err);
18443
18542
  }
18444
18543
  }
18445
18544
  };
18446
- applyCondition = (ifCondition, thenConditions) => {
18447
- for (const address of [ifCondition.source, ifCondition.target]) {
18448
- if (!isAddress(address)) continue;
18545
+ applyCondition = ({ if: ifConditions = [], then: thenConditions = [] }) => {
18546
+ const clauseGroups = groupIfConditions(ifConditions);
18547
+ let running = false;
18548
+ const run = (evt) => {
18549
+ if (running) return;
18550
+ running = true;
18551
+ try {
18552
+ if (this.evaluateClauseGroups(clauseGroups)) for (const thenCondition of thenConditions) this.execResult(thenCondition, evt);
18553
+ } finally {
18554
+ running = false;
18555
+ }
18556
+ };
18557
+ const watchedAddresses = new Set(ifConditions.flatMap(({ source, target }) => [source, target]).filter(isAddress));
18558
+ for (const address of watchedAddresses) {
18449
18559
  const { component, options } = this.getComponent(address);
18450
- this.handleComponentCondition(options || component, ifCondition, thenConditions);
18560
+ this.listenForChanges(options || component, run);
18561
+ }
18562
+ run({ target: null });
18563
+ };
18564
+ /**
18565
+ * @param {Array<Array<Object>>} clauseGroups output of groupIfConditions
18566
+ * @return {Boolean} true when every clause of at least one group matches
18567
+ */
18568
+ evaluateClauseGroups = (clauseGroups) => clauseGroups.some((group) => group.length && group.every((clause) => this.evaluateCondition(clause)));
18569
+ listenForChanges = (component, handler) => {
18570
+ if (!component) return;
18571
+ if (isNodeCollection(component)) {
18572
+ for (const elem of component) this.listenForChanges(elem, handler);
18573
+ return;
18451
18574
  }
18575
+ const listenerEvent = LISTEN_TYPE_MAP(component);
18576
+ if (listenerEvent) component.addEventListener(listenerEvent, handler, false);
18452
18577
  };
18453
18578
  /**
18454
18579
  * Evaulate conditions
18455
18580
  */
18456
18581
  evaluateCondition = ({ source, sourceProperty, targetProperty, comparison, target }) => {
18582
+ if (!isAddress(source) || !this.getComponent(source)?.component) return false;
18457
18583
  const sourceValue = this.getComponentProperty(source, sourceProperty);
18458
18584
  if (typeof sourceValue === "boolean") return sourceValue;
18459
18585
  const targetValue = String(isAddress(target) ? this.getComponentProperty(target, targetProperty) : target);
@@ -18480,7 +18606,10 @@ var FormeoRenderer$1 = class {
18480
18606
  const result = { component: null };
18481
18607
  if (!isAddress(address)) return null;
18482
18608
  const [, componentId, optionsKey, optionIndex] = splitAddress(address);
18483
- const component = this.renderedForm.querySelector(`#f-${componentId}`);
18609
+ let component = null;
18610
+ try {
18611
+ component = this.renderedForm.querySelector(`#f-${componentId}`);
18612
+ } catch {}
18484
18613
  if (!component) return result;
18485
18614
  result.component = component;
18486
18615
  if (optionsKey) {
@@ -18499,6 +18628,11 @@ var FormeoRenderer$1 = class {
18499
18628
  return components;
18500
18629
  };
18501
18630
  };
18631
+ var isCheckable = (elem) => ["checkbox", "radio"].includes(elem?.type);
18632
+ var checkableInputs = (fields) => {
18633
+ if (isCheckable(fields)) return [fields];
18634
+ return fields?.length && isCheckable(fields[0]) ? Array.from(fields) : null;
18635
+ };
18502
18636
  var isDomNode = (value) => Boolean(value) && typeof value.nodeType === "number";
18503
18637
  var isNodeCollection = (value) => Boolean(value) && !isDomNode(value) && typeof value.length === "number";
18504
18638
  var tagName = (component) => component.tagName?.toLowerCase();
package/dist/formeo.css CHANGED
@@ -1,7 +1,7 @@
1
1
 
2
2
  /**
3
3
  formeo - https://formeo.io
4
- Version: 5.1.3
4
+ Version: 5.2.1
5
5
  Author: Draggable https://draggable.io
6
6
  */
7
7