Jake Vanderwerf
2026-02-08 df6c00db050e188a6bd5707e72c4f1f331ced923
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
         },
@@ -334,7 +335,7 @@
         });
      }
      if (Object.hasOwn(field.dataset, 'repeater-id') || Object.hasOwn(field.dataset,'tag-list-id')) {
      if (field.dataset.fieldType === 'repeater' || field.dataset.fieldType === 'tag-list') {
         this.updateCollectionField(field);
         return;
      }
@@ -763,14 +764,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 +780,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) {
@@ -824,7 +825,7 @@
                        manyRefs.inputs?.forEach(input => {
                           window.prefixInput(input, `${data.repeater.dataset.fieldName}:${index}:`, el);
                           window.prefixInput(input, `${data.repeater.dataset.field}:${index}:`, el);
                        });
                     }
                  },
@@ -853,10 +854,8 @@
            }
            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]'));
               }
            }
@@ -882,10 +881,10 @@
                  form: form.dataset.formId,
                  format: field.dataset.tagFormat??'first_field'
               };
               console.log('Registering Tag List with config', config);
               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(
@@ -902,7 +901,7 @@
                        el.dataset.index = index;
                        manyRefs.inputs?.forEach(input => {
                           let wrapper = input.closest('.tag-item');
                           window.prefixInput(input, `${el.dataset.fieldName}:${index}:`, wrapper)
                           window.prefixInput(input, `${data.fieldName}:${index}:`, wrapper)
                        });
                        if (refs.label) {
@@ -914,7 +913,6 @@
               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);
               console.log('Adding tag list listeners to ', field);
               this.addTagListListeners(field);
            });
@@ -931,82 +929,114 @@
            handleTagListClick(e) {
               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;
                  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;
            let data = {};
            let hasValue = false;
            let isValid = true;
                     //clear values and validation
                     if (['checkbox', 'radio'].includes(input.type)) {
                        input.checked = false;
                     } else {
                        input.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);
               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 (config.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]');
@@ -1397,28 +1427,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');
@@ -1426,12 +1448,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}`
@@ -1439,7 +1458,6 @@
         window.jvbA11y.announce(announcement);
      }
      // Trigger custom event
      form.dispatchEvent(new CustomEvent('jvb-form-error', {
         detail: data
      }));
@@ -1502,6 +1520,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);