Jake Vanderwerf
2026-02-17 a24a06002081ad71a78ffeff9072725ba39cf121
assets/js/concise/FormController.js
@@ -59,7 +59,7 @@
            field: '.field',              //querySelectorAll
            label: 'label',
            success: '.success',
            error: '.success',
            error: '.error',
            message: '.validation-message',
         },
         repeater: {
@@ -78,6 +78,7 @@
            remove: '.remove-tag',
            label: '.tag-label',
            items: '.tag-items',
            item: '.tag-item',
            inputs: this.inputSelectors,           //querySelectorAll
            value: 'input[type="hidden"]'    //querySelectorAll
         },
@@ -91,7 +92,7 @@
            input: 'input[type="number"]'
         },
         limits: {
            hasLimit: '[data-limit]',
            hasLimit: '[data-maxlength]',
            limit: '.limit',
            current: '.current',
         }
@@ -326,6 +327,25 @@
      let field = this.getField(e.target);
      // Check if this input lives inside a collection field
      const collectionField = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]');
      if (collectionField) {
         // Dependencies still need checking
         if (this.dependencies.has(field.dataset.field)) {
            let dependency = this.dependencies.get(field.dataset.field);
            dependency.items.forEach(item => {
               this.checkFieldDependency(item, field.dataset.field);
            });
         }
         const collectionName = collectionField.dataset.field;
         window.debouncer.schedule(
            `collection:${collectionName}`,
            () => this.updateCollectionField(collectionField),
            150
         );
         return;
      }
      //Dependencies
      if (this.dependencies.has(field.dataset.field)) {
         let dependency = this.dependencies.get(field.dataset.field);
@@ -334,11 +354,6 @@
         });
      }
      if (Object.hasOwn(field.dataset, 'repeater-id') || Object.hasOwn(field.dataset,'tag-list-id')) {
         this.updateCollectionField(field);
         return;
      }
      let form = this.getForm(e.target);
      this.updateItem(field.dataset.field, this.getFieldValue(e.target), form);
   }
@@ -353,10 +368,18 @@
      window.debouncer.cancel(`form:${form.id}:validate:${fieldName}`);
      this.validateField(e.target);
      // If inside a collection, update the whole collection instead
      const collectionField = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]');
      if (collectionField) {
         this.updateCollectionField(collectionField);
         return;
      }
      this.updateItem(fieldName, this.getFieldValue(e.target), form);
   }
   handleInput(e){
      if (e.target.closest('[data-ignore]') || this.isRestoring) return;
      let form = this.getForm(e.target);
      if (!form) return;
@@ -718,7 +741,8 @@
            }
         }
         let inputs = Array.from(container.querySelectorAll(this.inputSelectors));
         let inputs = Array.from(container.querySelectorAll(this.inputSelectors))
            .filter(input => !input.closest('.ql-clipboard'));
         inputs.map(input => {
            this.getItem(input, config?.id);
         });
@@ -763,14 +787,14 @@
               let conf = this.quantityFields.get(e.target.closest('[data-num-id]')?.dataset.numId);
               if(!conf) return;
               let change = 0;
               if (conf.increase.contains(e.target)) {
               if (conf.ui.increase.contains(e.target)) {
                  change++;
               } else if (conf.decrease.contains(e.target)) {
               } else if (conf.ui.decrease.contains(e.target)) {
                  change--;
               }
               if (change === 0) return;
               let field = this.getField(e.target);
               let step = conf.input.step;
               let step = conf.ui.input.step;
               step = Math.max(step, 1);
               if (e.ctrlKey && e.shiftKey) {
                  step = step * 50;
@@ -779,20 +803,20 @@
               } else if (e.shiftKey) {
                  step = step * 10;
               }
               let value = (conf.input.value === '') ? 0 : parseFloat(conf.input.value);
               conf.input.value = (value + (step * change));
               let value = (conf.ui.input.value === '') ? 0 : parseFloat(conf.ui.input.value);
               conf.ui.input.value = (value + (step * change));
               value = parseFloat(conf.input.value);
               value = parseFloat(conf.ui.input.value);
               if (conf.input.min && value < conf.input.min) {
                  conf.input.value = conf.input.min;
                  conf.decrease.disabled = true;
               } else if (conf.input.max && value > conf.input.max) {
                  conf.input.value = conf.input.max;
                  conf.increase.disabled = true;
               if (conf.ui.input.min && value < conf.ui.input.min) {
                  conf.ui.input.value = conf.ui.input.min;
                  conf.ui.decrease.disabled = true;
               } else if (conf.ui.input.max && value > conf.ui.input.max) {
                  conf.ui.input.value = conf.ui.input.max;
                  conf.ui.increase.disabled = true;
               } else {
                  if (conf.decrease.disabled) conf.decrease.disabled = false;
                  if (conf.increase.disabled) conf.increase.disabled = false;
                  if (conf.ui.decrease.disabled) conf.ui.decrease.disabled = false;
                  if (conf.ui.increase.disabled) conf.ui.increase.disabled = false;
               }
            }
         checkForRepeaters(form) {
@@ -807,6 +831,7 @@
                  element: repeater,
                  field: this.getField(repeater),
                  sortable: false,
                  rows: []
               };
               if (!config.ui.add) return;
@@ -822,9 +847,8 @@
                        let index = config.ui.items?.children?.length??0;
                        el.dataset.index = index;
                        manyRefs.inputs?.forEach(input => {
                           window.prefixInput(input, `${data.repeater.dataset.fieldName}:${index}:`, el);
                           window.prefixInput(input, `${data.repeater.dataset.field}:${index}:`, el, false, true);
                        });
                     }
                  },
@@ -839,6 +863,7 @@
                     }
                  });
               }
               repeater.dataset.repeaterId = config.id;
               this.addRepeaterListeners(repeater);
               this.repeaters.set(config.id, config);
@@ -853,18 +878,26 @@
            }
            handleRepeaterClick(e) {
               if (e.target.matches(this.selectors.repeater.add)) {
                  console.log('Add Repeater Row');
                  this.addRepeaterRow(e.target.closest('[data-repeater-id]'));
               } else if (e.target.matches(this.selectors.repeater.remove)) {
                  console.log('Remove Repeater Row');
                  this.removeRepeaterRow(e.target.closest('[data-index]'));
               }
            }
            addRepeaterRow(repeater) {
               let data = {};
               data.repeater = repeater;
               repeater.append(this.templates.create(repeater.dataset.repeaterId, data));
               this.initializeFields(repeater, this.getField(repeater).config??{});
               let config = this.repeaters.get(repeater.dataset.repeaterId);
               let row = this.templates.create(repeater.dataset.repeaterId, data);
               config.rows.push({
                  element: row,
                  fields: Array.from(row.querySelectorAll('[data-field]'))
               });
               this.repeaters.set(config.id, config);
               config.ui.items.append(row);
               let form = this.getForm(repeater);
               this.initializeFields(repeater, form);
               this.a11y.announce('Row added');
            }
            removeRepeaterRow(row) {
@@ -885,6 +918,7 @@
               if (!config.ui.input || !config.ui.add || !config.ui.items) return;
               field.dataset.tagListId = config.id;
               config.fieldName = field.dataset.field;
               let template = field.querySelector('template');
               this.templates.define(
@@ -900,8 +934,8 @@
                        let index = config.ui.items?.children?.length??0;
                        el.dataset.index = index;
                        manyRefs.inputs?.forEach(input => {
                           let wrapper = window.closest('.tag-item');
                           window.prefixInput(input, `${el.dataset.fieldName}:${index}:`, wrapper)
                           let wrapper = input.closest('.tag-item');
                           window.prefixInput(input, `${data.fieldName}:${index}:`, wrapper, false, true)
                        });
                        if (refs.label) {
@@ -910,7 +944,8 @@
                     }
                  },
               );
               config.ui.inputs = Array.from(field.querySelectorAll(this.selectors.tagList.inputs));
               config.ui.value = Array.from(field.querySelectorAll(this.selectors.tagList.value));
               this.tagLists.set(config.id, config);
               this.addTagListListeners(field);
            });
@@ -918,93 +953,124 @@
         }
            addTagListListeners(el) {
               el.addEventListener('click', this.tagListClick);
               el.addEventListener('keypress', this.tagListInput, {passive: true})
               el.addEventListener('keypress', this.tagListInput);
            }
            removeTagListListeners(el) {
               el.removeEventListener('click', this.tagListClick);
               el.removeEventListener('keypress', this.tagListInput)
               el.removeEventListener('keypress', this.tagListInput);
            }
            handleTagListClick(e) {
               if (e.target.matches(this.selectors.tagList.add)) {
               if (window.targetCheck(e,this.selectors.tagList.add)) {
                  this.addTagListItem(e.target.closest('[data-tag-list-id]'));
               } else if (e.target.matches(this.selectors.tagList.remove)) {
                  this.removeTagListItem(e.target.closest(this.selectors.tagList.remove));
               } else if (window.targetCheck(e, this.selectors.tagList.remove)) {
                  this.removeTagListItem(e.target.closest(this.selectors.tagList.item));
               }
            }
               addTagListItem(tagList) {
                  let config = this.tagLists.get(tagList.dataset.tagListId);
                  if (!config) return;
         addTagListItem(tagList) {
            let config = this.tagLists.get(tagList.dataset.tagListId);
            if (!config) return;
                  let data = {};
                  let hasValue = false;
            let data = {};
            let hasValue = false;
            let isValid = true;
                  for (let input of config.ui.inputs) {
                     this.validateField(input);
                     const fieldName = input.name.replace('new_','');
                     const value = this.getFieldValue(input);
                     if (value) hasValue = true;
                     data[fieldName] = value;
            // First pass: validate all inputs
            for (let input of config.ui.inputs) {
               const isRequired = input.required || input.dataset.required === 'true';
               const value = this.getFieldValue(input);
                     //clear values and validation
                     if (['checkbox', 'radio'].includes(input.type)) {
                        input.checked = false;
                     } else {
                        input.value = '';
               if (value) hasValue = true;
               // Validate and check for errors
               const valid = this.validateField(input);
               if (isRequired && !value) {
                  this.showError(input, 'This field is required');
                  isValid = false;
               } else if (!valid) {
                  isValid = false;
               }
               const fieldName = input.name.replace('new_','');
               data[fieldName] = value;
            }
            // Stop if validation failed
            if (!isValid) {
               this.a11y.announce('Please correct the errors before adding');
               const firstInvalid = config.ui.inputs.find(input => {
                  const isRequired = input.required || input.dataset.required === 'true';
                  return (isRequired && !this.getFieldValue(input));
               });
               if (firstInvalid) firstInvalid.focus();
               return;
            }
            if (!hasValue) {
               this.a11y.announce('Please fill in at least one field');
               config.ui.inputs[0].focus();
               return;
            }
            // Build label
            let label;
            switch (config.format) {
               case 'first_field':
                  label = Object.values(data)[0];
                  break;
               case 'all_fields':
                  label = Object.values(data).join(', ');
                  break;
               default:
                  if (config.format.includes('{')) {
                     label = config.format;
                     for (const [key, value] of Object.entries(data)) {
                        label = label.replace(`{${key}}`, value);
                     }
                     this.clearValidation(input);
                  } else {
                     label = data[config.format]??Object.values(data)[0];
                  }
                  break;
            }
                  if (!hasValue) {
                     this.a11y.announce('Please fill in at least one field');
                     config.ui.inputs[0].focus();
                     return;
                  }
            let newItem = this.templates.create(tagList.dataset.tagListId, {
               label: label,
               fieldName: config.fieldName
            });
                  let label;
                  switch (config.format) {
                     case 'first_field':
                        label = Object.values(data)[0];
                        break;
                     case 'all_fields':
                        label = Object.values(data).join(', ');
                        break;
                     default:
                        if (format.includes('{')) {
                           let label = config.format;
                           for (const [key, value] of Object.entries(data)) {
                              label = label.replace(`{${key}}`, value);
                           }
                        } else {
                           label = data[config.format]??Object.values(data)[0];
                        }
                        break;
                  }
            const index = config.ui.items?.children?.length ?? 0;
            newItem?.querySelectorAll('input[type=hidden]')?.forEach(input => {
               const fieldKey = input.dataset.field;
               input.name = `${config.fieldName}:${index}:${fieldKey}`;
               input.id = `${config.fieldName}:${index}:${fieldKey}`;
               input.value = data[fieldKey] || '';
            });
                  let newItem = this.templates.create(tagList.dataset.tagListId, {
                     label: label
                  });
            config.ui.items.append(newItem);
                  const index = config.ui.items?.children?.length ?? 0;
                  newItem?.querySelectorAll('input[type=hidden]')?.forEach(input => {
                     const fieldKey = input.dataset.field;
                     input.name = `${config.element.field}:${index}:${fieldKey}`;
                     input.value = data[fieldKey] || '';
                  });
                  config.ui.items.append(newItem);
                  config.ui.inputs[0]?.focus();
                  this.updateCollectionField(tagList);
                  this.a11y.announce('Item added');
            // Clear inputs AFTER success
            for (let input of config.ui.inputs) {
               if (['checkbox', 'radio'].includes(input.type)) {
                  input.checked = false;
               } else {
                  input.value = '';
               }
               removeTagListItem(tag) {
                  let tagList = tag.closest('[data-tag-list-id]');
                  tag.remove();
                  this.reindexList(tagList);
                  this.a11y.announce('Item removed');
               }
               this.clearValidation(input);
            }
            config.ui.inputs[0]?.focus();
            this.updateCollectionField(tagList);
            this.a11y.announce('Item added');
         }
            removeTagListItem(item) {
               let tagList = item.closest('[data-tag-list-id]');
               if (!tagList) return;
               item.remove();
               this.reindexList(tagList);
               this.updateCollectionField(tagList);
               this.a11y.announce('Item removed');
            }
            handleTagListInput(e) {
               let target = e.target;
               let field = target.closest('[data-tag-list-id]');
@@ -1097,25 +1163,32 @@
                  }
               });
            }
         checkForCharacterLimits(form) {
            if (!form.querySelector(this.selectors.limits.hasLimit)) return;
            this.countUpdaters = this.updateCount.bind(this);
   checkForCharacterLimits(form) {
      if (!form.querySelector(this.selectors.limits.hasLimit)) return;
      this.countUpdaters = this.updateCount.bind(this);
            form.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach(input => {
               let id = window.generateID('limit');
               input.dataset.charLimitId = id;
               let config = {
                  element: input,
                  form: form.dataset.formId,
                  ui: window.uiFromSelectors(this.selectors.limits, input.closest('.field'))
               };
               config.ui.limit.textContent = input.dataset.limit;
               this.charLimits.set(id, config);
      form.querySelectorAll(this.selectors.limits.hasLimit).forEach(field => {
         const input = this.getFieldInput(field);
         if (!input) return;
               this.addCharacterLimitListeners(input);
            });
         let id = window.generateID('limit');
         input.dataset.charLimitId = id;
         input.dataset.limit = field.dataset.maxlength;
         let config = {
            element: input,
            form: form.dataset.formId,
            ui: window.uiFromSelectors(this.selectors.limits, field)
         };
         if (config.ui.limit) {
            config.ui.limit.textContent = field.dataset.maxlength;
         }
         this.charLimits.set(id, config);
         this.addCharacterLimitListeners(input);
      });
   }
            addCharacterLimitListeners(input) {
               input.addEventListener('input', this.countUpdaters, {passive: true});
            }
@@ -1218,7 +1291,9 @@
            window.prefixInput(
               input,
               `${fieldName}:${index}:`,
               item  // Pass the item as wrapper for label lookup
               item,
               false,
               true
            );
         });
      });
@@ -1242,7 +1317,7 @@
      if (!form) return;
      // Get all current data for the collection
      const value = this.getFieldValue(field.querySelector('input, select, textarea'));
      const value = this.getFieldValue(field);
      this.updateItem(field.dataset.field, value, form);
   }
   /**********************************************************************
@@ -1395,28 +1470,20 @@
      if (data.field) {
         const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`);
         if (fieldWrapper) {
            // Use existing showError method for consistency
            this.showError(fieldWrapper, data.message);
            // Mark as touched so validation persists
            this.touchedFields.add(data.field);
            // Scroll to error
            fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
            // Focus the input for better UX
            const input = fieldWrapper.querySelector('input, textarea, select');
            if (input) {
               input.focus();
            }
         }
      } else {
         // General form error (not field-specific)
         const error = document.createElement('div');
         error.className = 'form-error error-message';
         error.textContent = data.message;
         // Add icon for consistency
         const icon = window.getIcon?.('close-circle');
         if (icon) {
            icon.classList.add('error-icon');
@@ -1424,12 +1491,9 @@
         }
         form.insertBefore(error, form.firstChild);
         // Scroll to top to show the error
         form.scrollIntoView({ behavior: 'smooth', block: 'start' });
      }
      // Announce error for accessibility
      if (window.jvbA11y) {
         const announcement = data.field
            ? `Error in ${data.field}: ${data.message}`
@@ -1437,7 +1501,6 @@
         window.jvbA11y.announce(announcement);
      }
      // Trigger custom event
      form.dispatchEvent(new CustomEvent('jvb-form-error', {
         detail: data
      }));
@@ -1500,6 +1563,7 @@
   **********************************************************************/
   getForm(element) {
      let form = element.closest('[data-form-id]');
      if (!form) return false;
      let id = form.dataset.formId;
      if (!id) return false;
      let config = this.forms.get(id);
@@ -1536,10 +1600,13 @@
         case 'selector':
         case 'upload':
         case 'gallery':
         case 'image':
            return this.getHiddenInputValue(element, conf, fieldName);
         case 'true-false':
            return element.value === '1'||element.value === 'on'||element.value ==='true';
         case 'toggle-text':
            return element.checked;
         case 'checkbox':
            // Handle multi-checkbox (name ends with [])
            if (element.name.endsWith('[]')) {
@@ -1593,20 +1660,31 @@
      return typeof value === 'object' && Object.keys(value).length === 0;
   }
      getRepeaterValue(element, conf) {
         if (!conf.container) {
            conf.container = conf.field?.querySelector('.repeater-items');
            this.saveItem(conf);
         }
         const items = element.querySelector('.repeater-items');
         if (!items) return [];
         let ignore = ['image_data','image-title','image-caption','image-description','image-alt-text']
         let value = [];
         Array.from(conf.container.children).forEach(row => {
         Array.from(items.children).forEach(row => {
            let rowData = {};
            row.querySelectorAll('[data-field]').forEach(field => {
               rowData[field.dataset.field] = this.getFieldValue(field);
               if (!ignore.includes(field.dataset.field)) {
                  const input = this.getFieldInput(field);
                  if (input) {
                     rowData[field.dataset.field] = this.getFieldValue(input);
                  }
               }
            });
            value.push(rowData);
         });
         return value;
      }
   getFieldInput(field) {
      // For quill fields, target the specific editor textarea
      const quillTextarea = field.querySelector('textarea[data-editor]');
      if (quillTextarea) return quillTextarea;
      return field.querySelector(this.inputSelectors);
   }
      getTagListValue(element, conf) {
         if (!conf.container) {
            conf.container = conf.field?.querySelector('.tag-items');
@@ -1634,13 +1712,14 @@
         });
         return value;
      }
      getHiddenInputValue(element, conf, fieldName) {
         if (!conf.value) {
            conf.value = conf.field?.querySelector(`input[type=hidden][name="${fieldName}"]`);
            this.saveItem(conf);
         }
         return conf.value.value;
   getHiddenInputValue(element, conf, fieldName) {
      if (!conf.value) {
         conf.value = conf.field?.querySelector(`input[type=hidden][name="${fieldName}"]`)
            || conf.field?.querySelector(`input[type=hidden]`);
         this.saveItem(conf);
      }
      return conf.value?.value ?? '';
   }
   /**
    * Format field value for display in summary
@@ -1680,6 +1759,8 @@
         case 'selector':
         case 'upload':
         case 'image':  //legacy, shouldn't be needed
         case 'gallery':   //legacy, shouldn't be needed
            // These might need special handling depending on your needs
            return this.formatHiddenFieldForSummary(value, input, fieldType);
@@ -1802,7 +1883,7 @@
    * Format hidden field types (upload, selector)
    */
   formatHiddenFieldForSummary(value, input, fieldType) {
      if (fieldType === 'upload') {
      if (['upload', 'gallery', 'image'].includes(fieldType)) {
         // Get upload preview images if available
         const uploadField = input.field?.querySelector('[data-upload-field]');
         if (uploadField) {
@@ -1944,10 +2025,10 @@
         });
         this.tagLists.clear();
      }
      if(this.charLimits.size > 0) {
      if (this.charLimits.size > 0) {
         Array.from(this.charLimits.values()).forEach(charLimit => {
            charLimit.removeEventListener('input', this.countUpdaters);
         })
            charLimit.element.removeEventListener('input', this.countUpdaters);
         });
      }
      this.inputs.clear();
      this.forms.clear();