Jake Vanderwerf
2026-05-12 457c329237f97069063e641b10f384a52d584f21
assets/js/concise/FormController.js
@@ -19,6 +19,7 @@
      this.isRestoring = false;
      this.hasListeners = false;
      this.hasUploads = false;
      this.summaryTemplate = false;
      this.init();
@@ -30,6 +31,20 @@
      this.initListeners();
      this.initStore();
      this.initValidators();
      this.initUploadSubscription();
   }
   initUploadSubscription() {
      window.jvbUploads.subscribe((event, data) => {
         if (!this.hasUploads) return;
         if (event === 'upload-received') {
            let form = this.getForm(data.field);
            if (form) {
               this.updateItem(`${data.field.dataset.field}_tempUpload`, data.id, form);
            }
         }
      });
   }
   initElements() {
      this.inputSelectors = 'input, textarea, select';
@@ -51,7 +66,12 @@
               status: '.fstatus',
               message: '.fstatus .message',
               icon: '.fstatus .icon',
               actions: '.fstatus .actions'
               actions: '.fstatus .actions',
            },
            restore: {
               container: '.restore-form',
               restore: '[data-action="restore"]',
               clear: '[data-action="clear"]',
            }
         },
         inputs: this.inputSelectors,              //querySelectorAll
@@ -109,20 +129,20 @@
      this.tagListClick = this.handleTagListClick.bind(this);
      this.tagListInput = this.handleTagListInput.bind(this);
   }
      addFormListeners(form) {
         form.addEventListener('click', this.clickHandler);
         form.addEventListener('change', this.changeHandler);
         form.addEventListener('input', this.inputHandler);
         form.addEventListener('blur', this.blurHandler);
         form.addEventListener('submit', this.submitHandler);
      }
      removeFormListeners(form) {
         form.removeEventListener('click', this.clickHandler);
         form.removeEventListener('change', this.changeHandler);
         form.removeEventListener('input', this.inputHandler);
         form.removeEventListener('blur', this.blurHandler);
         form.removeEventListener('submit', this.submitHandler);
      }
   addFormListeners(form) {
      form.addEventListener('click', this.clickHandler);
      form.addEventListener('change', this.changeHandler);
      form.addEventListener('input', this.inputHandler);
      form.addEventListener('blur', this.blurHandler);
      form.addEventListener('submit', this.submitHandler);
   }
   removeFormListeners(form) {
      form.removeEventListener('click', this.clickHandler);
      form.removeEventListener('change', this.changeHandler);
      form.removeEventListener('input', this.inputHandler);
      form.removeEventListener('blur', this.blurHandler);
      form.removeEventListener('submit', this.submitHandler);
   }
   initStore() {
      const store = window.jvbStore.register(
         'forms',
@@ -153,41 +173,59 @@
         }
      });
   }
      showPendingNotification(formId, changes) {
         let form = this.forms.get(formId);
   showPendingNotification(formId, changes) {
      let form = this.forms.get(formId);
      if (!form) return;
      let element = form.element;
      if (!element) {
         console.warn(`Form element not found for: ${formId}`);
         return;
      }
      form.ui.restore.container.hidden = false;
      const handleRestore = async (changes, element) => {
         this.isRestoring = true;
         let theChanges = {['fields']: changes};
         await this.checkStoredUploads(changes, element);
         this.populate.populate(element, theChanges);
         this.a11y.announce('Previous changes restored');
         this.isRestoring = false;
         form.ui.restore.container.remove();
      };
      const clearRestore = async (formId) => {
         await this.checkStoredUploads(changes, element, false);
         await this.store.delete(formId);
         this.a11y.announce('Previous changes discarded');
         form.ui.restore.container.remove();
      };
      form.ui.restore.restore.addEventListener('click', () => handleRestore(changes, element));
      form.ui.restore.clear.addEventListener('click', async ()  => clearRestore(formId));
   }
      async checkStoredUploads(changes, element, restore = true) {
         let form = this.forms.get(element.dataset.formId);
         if (!form) return;
         let element = form.element;
         if (!element) {
            console.warn(`Form element not found for: ${formId}`);
            return;
         let uploads = [];
         for (let [key, value] of Object.entries(changes)) {
            if (key.includes('_tempUpload')) {
               let field = key.replace('_tempUpload', '');
               if (Object.hasOwn(form.ui.uploads, field)) {
                  uploads = [
                     ... uploads,
                     ... value
                  ];
               }
            }
         }
         const notification = document.createElement('div');
         notification.className = 'pendingChanges';
         notification.innerHTML = `
         <p>We noticed unsaved changes from last time. Would you like to restore them?</p>
        <button class="restore" type="button" data-form-id="${formId}">Restore</button>
        <button class="discard" type="button" data-form-id="${formId}">Discard</button>`;
         if (uploads.length > 0) {
            if (restore) {
               await window.jvbUploads.restoreUploads(uploads);
            } else {
               await window.jvbUploads.clearUploads(uploads);
            }
         element.insertBefore(notification, form.ui.status.status);
         notification.querySelector('.restore').addEventListener('click', async () => {
            this.isRestoring = true;
            let theChanges = {['fields']: changes};
            this.populate.populate(element, theChanges);
            this.a11y.announce('Previous changes restored');
            this.isRestoring = false;
            notification.remove();
         });
         notification.querySelector('.discard').addEventListener('click', async () => {
            await this.store.delete(formId);
            this.a11y.announce('Previous changes discarded');
            notification.remove();
         });
         }
      }
   initValidators() {
      this.validators = {
@@ -327,6 +365,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);
@@ -335,11 +392,6 @@
         });
      }
      if (field.dataset.fieldType === 'repeater' || field.dataset.fieldType === 'tag-list') {
         this.updateCollectionField(field);
         return;
      }
      let form = this.getForm(e.target);
      this.updateItem(field.dataset.field, this.getFieldValue(e.target), form);
   }
@@ -354,6 +406,13 @@
      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);
   }
@@ -417,6 +476,7 @@
    * @param form
    */
   updateItem(name, value, form) {
      if (value === undefined) return;
      if (!this.changes.has(form.id)) {
         this.changes.set(form.id, {
            id: form.id,
@@ -426,7 +486,16 @@
         });
      }
      let changes = this.changes.get(form.id);
      changes.changes[name] = value;
      //If it is temporary uploads, we need to store them all
      if (name.includes('_tempUpload')) {
         if (!Object.hasOwn(changes.changes, name)) {
            changes.changes[name] = [];
         }
         changes.changes[name].push(value);
      } else {
         changes.changes[name] = value;
      }
      this.changes.set(form.id, changes);
      if (form.options.cache) {
         this.scheduleBackup();
@@ -493,6 +562,17 @@
    * @param {object} options
    */
   registerForm(form, options) {
      options = {
         autoUpload: false,
         imageMeta: true,
         delay: 1500,
         endpoint: Object.hasOwn(form.dataset, 'save') ? form.dataset.save: '',
         showStatus: true,
         showSummary: false,
         cache: true,
         ignore: [],
         ... options
      };
      //Bail if form already registered
      if (Object.hasOwn(form.dataset, 'formId') && this.forms.has(form.dataset.formId)) return;
@@ -507,16 +587,7 @@
         element: form,
         id: formId,
         status: '',
         options: {
            autoUpload: options.autoUpload??false,
            imageMeta: options.imageMeta??true,
            delay: options.delay??1500,
            endpoint: options.save??form.dataset.save??'',
            showStatus: options.showStatus??true,
            showSummary: options.showSummary??false,
            cache: options.cache??true,
            ignore: options.ignore??[]
         },
         options: options,
         ui: window.uiFromSelectors(this.selectors.forms, form)
      };
@@ -525,618 +596,629 @@
      return config;
   }
      clearForm(formId) {
         const config = this.forms.get(formId);
         if (!config) return;
   clearForm(formId) {
      const config = this.forms.get(formId);
      if (!config) return;
         if (config.unsubscribeTabs) {
            config.unsubscribeTabs();
      if (config.unsubscribeTabs) {
         config.unsubscribeTabs();
      }
      if(config.tabs) {
         window.jvbTabs.removeTab(config.element);
      }
      if (config.cache && this.changes.has(formId)) this.saveCache(formId);
      // Cleanup items
      for (let [id, input] of this.inputs.entries()) {
         if (input.form === formId) {
            this.inputs.delete(id);
         }
         if(config.tabs) {
            window.jvbTabs.removeTab(config.element);
      }
      // Clean up dependencies for this form
      this.dependencies.forEach((dependency, fieldName) => {
         dependency.items = dependency.items.filter(item => item.form !== formId);
         // Remove the dependency entry entirely if no items left
         if (dependency.items.length === 0) {
            this.dependencies.delete(fieldName);
         }
      });
         if (config.cache && this.changes.has(formId)) this.saveCache(formId);
      if (Object.hasOwn(config, 'hasQuill') && this.quillInstances.has(formId)) {
         const instances = this.quillInstances.get(formId);
         instances.forEach(quillInstance => {
            // Disable the editor
            quillInstance.disable();
         // Cleanup items
         for (let [id, input] of this.inputs.entries()) {
            if (input.form === formId) {
               this.inputs.delete(id);
            // Remove all event listeners
            quillInstance.off('text-change');
            quillInstance.off('selection-change');
            // Get the container elements
            const container = quillInstance.container.parentElement;
            const toolbar = container?.querySelector('.ql-toolbar');
            // Remove toolbar
            if (toolbar) {
               toolbar.remove();
            }
         }
         // Clean up dependencies for this form
         this.dependencies.forEach((dependency, fieldName) => {
            dependency.items = dependency.items.filter(item => item.form !== formId);
            // Remove the dependency entry entirely if no items left
            if (dependency.items.length === 0) {
               this.dependencies.delete(fieldName);
            // Clear the editor content
            quillInstance.setText('');
            // Remove container
            if (container && container.classList.contains('editor-container')) {
               const textarea = container.nextElementSibling;
               if (textarea?.tagName === 'TEXTAREA') {
                  textarea.style.display = '';
               }
               container.remove();
            }
         });
         if (Object.hasOwn(config, 'hasQuill') && this.quillInstances.has(formId)) {
            const instances = this.quillInstances.get(formId);
            instances.forEach(quillInstance => {
               // Disable the editor
               quillInstance.disable();
               // Remove all event listeners
               quillInstance.off('text-change');
               quillInstance.off('selection-change');
               // Get the container elements
               const container = quillInstance.container.parentElement;
               const toolbar = container?.querySelector('.ql-toolbar');
               // Remove toolbar
               if (toolbar) {
                  toolbar.remove();
         this.quillInstances.delete(formId);
      }
      let checks = {
         repeater: this.repeaters,
         tagList: this.tagLists,
         charLimit: this.charLimits,
         quantity: this.quantityFields
      };
      for (let [type, check] of Object.entries(checks)) {
         if (check.size === 0) continue;
         let hasAny = Array.from(check.values()).filter(item => item.form === formId);
         if (hasAny.length > 0) {
            hasAny.forEach(item => {
               switch (type) {
                  case 'repeater':
                     this.removeRepeaterListeners(item.element);
                     break;
                  case 'tagList':
                     this.removeTagListListeners(item.element);
                     break;
                  case 'charLimit':
                     this.removeCharacterLimitListeners(item.element);
                     break;
                  case 'quantity':
                     this.removeQuantityListeners(item.element);
                     break;
               }
               // Clear the editor content
               quillInstance.setText('');
               // Remove container
               if (container && container.classList.contains('editor-container')) {
                  const textarea = container.nextElementSibling;
                  if (textarea?.tagName === 'TEXTAREA') {
                     textarea.style.display = '';
                  }
                  container.remove();
               if (check.has(item.id)) {
                  check.delete(item.id);
               }
            });
            this.quillInstances.delete(formId);
         }
         let checks = {
            repeater: this.repeaters,
            tagList: this.tagLists,
            charLimit: this.charLimits,
            quantity: this.quantityFields
         };
         for (let [type, check] of Object.entries(checks)) {
            if (check.size === 0) continue;
            let hasAny = Array.from(check.values()).filter(item => item.form === formId);
            if (hasAny.length > 0) {
               hasAny.forEach(item => {
                  switch (type) {
                     case 'repeater':
                        this.removeRepeaterListeners(item.element);
                        break;
                     case 'tagList':
                        this.removeTagListListeners(item.element);
                        break;
                     case 'charLimit':
                        this.removeCharacterLimitListeners(item.element);
                        break;
                     case 'quantity':
                        this.removeQuantityListeners(item.element);
                        break;
                  }
                  if (check.has(item.id)) {
                     check.delete(item.id);
                  }
               });
            }
         }
         this.removeFormListeners(config.element);
         this.forms.delete(formId);
         window.debouncer.cancel(`form_changes`);
      }
      defineSummaryTemplate() {
         this.summaryTemplate = true;
         let form = this;
         this.templates.define(
            'formSummary',
            {
               refs: {
                  result: '.result',
                  h3: 'h3',
                  p: 'p',
               },
               setup({ el, refs, manyRefs, data }) {
                  const skipFields = ['sendAll', ...data.config.options.ignore??[]];
                  for (let [key, value] of Object.entries(data.changes)) {
                     if (skipFields.includes(key) || form.isEmptyValue(value)) continue;
                     let input = Array.from(form.inputs.values())
                        .find(temp => temp.field?.dataset.field === key);
                     if (!input) continue;
                     let entry = refs.result.cloneNode(true);
                     let title = entry.querySelector('h3');
                     let p = entry.querySelector('p');
                     // Get field label - prioritize legend for fieldsets, then label
                     const legend = input.field?.querySelector('legend');
                     title.textContent = legend
                        ? legend.textContent.replace('*', '').trim()
                        : input.ui.label?.textContent.replace('*', '').trim();
                     const formattedValue = form.formatValueForSummary(value, input);
                     if (formattedValue instanceof HTMLElement) {
                        // If it's an HTML element (repeater, tag-list, etc.), replace <p>
                        p.replaceWith(formattedValue);
                     } else {
                        // If it's a string, set text content
                        p.textContent = formattedValue;
                     }
                     el.append(entry);
                  }
                  let uploads = data.config?.element?.querySelectorAll('[data-upload-field]');
                  if (uploads) {
                     uploads.forEach(upload => {
                        let label = upload.querySelector('h2')?.textContent??'Upload:';
                        let imgs = upload.querySelectorAll('.item-grid.preview img');
                        let field = refs.result.cloneNode(true);
                        if (imgs) {
                           let entry = refs.result.cloneNode(true);
                           let title = field.querySelector('h3');
                           let p = field.querySelector('p');
                           p?.remove();
                           if (title) title.textContent = label;
                           imgs.forEach(img => {
                              img = img.cloneNode(true);
                              entry.append(img);
                           });
                           el.append(entry);
                        }
                     });
                  }
                  refs.result?.remove();
                  data.config.element.after(el);
                  window.fade(data.config.element, false);
               }
            }
         );
      }
      initializeFields(container, config = null) {
         const fieldHandlers = {
            '[data-editor]': () => this.checkForQuill(container,config),
            'div.quantity': () => this.checkForQuantity(container),
            '.repeater': () => this.checkForRepeaters(container, config),
            '.field.tag-list': () => this.checkForTagLists(container),
            '[data-depends-on]': () => this.checkForConditionalFields(container),
            '[data-limit]': () => this.checkForCharacterLimits(container),
            '[data-uploader],[data-upload-field]': () => this.checkForImageUploads(container, config),
            'nav.tabs': () => this.checkForTabs(container, config),
            '[data-type="selector"]': () => this.checkForSelectors(container)
         };
      this.removeFormListeners(config.element);
      this.forms.delete(formId);
         for (const [selector, handler] of Object.entries(fieldHandlers)) {
            if (container.querySelector(selector)) {
               handler();
            }
         }
      window.debouncer.cancel(`form_changes`);
   }
   defineSummaryTemplate() {
      this.summaryTemplate = true;
      let form = this;
      this.templates.define(
         'formSummary',
         {
            refs: {
               result: '.result',
               h3: 'h3',
               p: 'p',
            },
            setup({ el, refs, manyRefs, data }) {
               const skipFields = ['sendAll', ...data.config.options.ignore??[]];
         let inputs = Array.from(container.querySelectorAll(this.inputSelectors));
         inputs.map(input => {
            this.getItem(input, config?.id);
         });
      }
         checkForQuill(form, config) {
            if (!form.querySelector('[data-editor]')) return;
            if (config && !Object.hasOwn(config, 'hasQuill')){
               config.hasQuill = true;
               this.forms.set(config.id, config);
            }
               for (let [key, value] of Object.entries(data.changes)) {
                  if (skipFields.includes(key) || form.isEmptyValue(value)) continue;
            if (!this.quillInstances.has(config.id)) {
               this.quillInstances.set(config.id, new Set());
            }
                  let input = Array.from(form.inputs.values())
                     .find(temp => temp.field?.dataset.field === key);
                  if (!input) continue;
            const instances = window.jvbQuill(form);
            instances.forEach(instance => {
               this.quillInstances.get(config.id).add(instance);
            });
         }
         checkForQuantity(form) {
            if (!form.querySelector(this.selectors.number.number)) return;
            form.querySelectorAll(this.selectors.number.number).forEach(num => {
               let config = {
                  id: window.generateID('quant'),
                  form: form.dataset.formId,
                  ui: window.uiFromSelectors(this.selectors.number, num),
                  element: num
               };
               num.dataset.numId = config.id;
               this.quantityFields.set(config.id, config);
               this.addQuantityListeners(num);
            });
         }
            addQuantityListeners(el) {
               el.addEventListener('click', this.quantityClick);
            }
            removeQuantityListeners(el) {
               el.removeEventListener('click', this.quantityClick);
            }
            handleQuantityClick(e) {
               let conf = this.quantityFields.get(e.target.closest('[data-num-id]')?.dataset.numId);
               if(!conf) return;
               let change = 0;
               if (conf.ui.increase.contains(e.target)) {
                  change++;
               } else if (conf.ui.decrease.contains(e.target)) {
                  change--;
                  let entry = refs.result.cloneNode(true);
                  let title = entry.querySelector('h3');
                  let p = entry.querySelector('p');
                  // Get field label - prioritize legend for fieldsets, then label
                  const legend = input.field?.querySelector('legend');
                  title.textContent = legend
                     ? legend.textContent.replace('*', '').trim()
                     : input.ui.label?.textContent.replace('*', '').trim();
                  const formattedValue = form.formatValueForSummary(value, input);
                  if (formattedValue instanceof HTMLElement) {
                     // If it's an HTML element (repeater, tag-list, etc.), replace <p>
                     p.replaceWith(formattedValue);
                  } else {
                     // If it's a string, set text content
                     p.textContent = formattedValue;
                  }
                  el.append(entry);
               }
               if (change === 0) return;
               let field = this.getField(e.target);
               let step = conf.ui.input.step;
               step = Math.max(step, 1);
               if (e.ctrlKey && e.shiftKey) {
                  step = step * 50;
               } else if (e.ctrlKey) {
                  step = step *5;
               } else if (e.shiftKey) {
                  step = step * 10;
               }
               let value = (conf.ui.input.value === '') ? 0 : parseFloat(conf.ui.input.value);
               conf.ui.input.value = (value + (step * change));
               value = parseFloat(conf.ui.input.value);
               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.ui.decrease.disabled) conf.ui.decrease.disabled = false;
                  if (conf.ui.increase.disabled) conf.ui.increase.disabled = false;
               }
            }
         checkForRepeaters(form) {
            if (!form.querySelector(this.selectors.repeater.repeater)) return;
            form.querySelectorAll(this.selectors.repeater.repeater).forEach(repeater => {
               let config = {
                  id: repeater.querySelector('template').className??window.generateID('repeater'),
                  ui: window.uiFromSelectors(this.selectors.repeater, repeater),
                  form: form.dataset.formId,
                  element: repeater,
                  field: this.getField(repeater),
                  sortable: false,
               };
               if (!config.ui.add) return;
               let template = repeater.querySelector('template');
               this.templates.define(
                  template.className,
                  {
                     manyRefs: {
                        inputs: this.inputSelectors,
                     },
                     setup({el, refs, manyRefs, data}) {
                        let index = config.ui.items?.children?.length??0;
                        el.dataset.index = index;
                        manyRefs.inputs?.forEach(input => {
                           window.prefixInput(input, `${data.repeater.dataset.field}:${index}:`, el);
               let uploads = data.config?.element?.querySelectorAll('[data-upload-field]');
               if (uploads) {
                  uploads.forEach(upload => {
                     let label = upload.querySelector('h2')?.textContent??'Upload:';
                     let imgs = upload.querySelectorAll('.item-grid.preview img');
                     let field = refs.result.cloneNode(true);
                     if (imgs) {
                        let entry = refs.result.cloneNode(true);
                        let title = field.querySelector('h3');
                        let p = field.querySelector('p');
                        p?.remove();
                        if (title) title.textContent = label;
                        imgs.forEach(img => {
                           img = img.cloneNode(true);
                           entry.append(img);
                        });
                     }
                  },
               );
               if (window.Sortable) {
                  config.sortable = new Sortable(repeater, {
                     handle: this.selectors.repeater.header,
                     animation: 150,
                     onEnd: () => {
                        this.reindexList(repeater);
                        el.append(entry);
                     }
                  });
               }
               repeater.dataset.repeaterId = config.id;
               this.addRepeaterListeners(repeater);
               this.repeaters.set(config.id, config);
            });
               refs.result?.remove();
               data.config.element.after(el);
               window.fade(data.config.element, false);
            }
         }
            addRepeaterListeners(el) {
               el.addEventListener('click', this.repeaterClick);
            }
            removeRepeaterListeners(el) {
               el.removeEventListener('click', this.repeaterClick);
            }
            handleRepeaterClick(e) {
               if (e.target.matches(this.selectors.repeater.add)) {
                  this.addRepeaterRow(e.target.closest('[data-repeater-id]'));
               } else if (e.target.matches(this.selectors.repeater.remove)) {
                  this.removeRepeaterRow(e.target.closest('[data-index]'));
               }
            }
            addRepeaterRow(repeater) {
               let data = {};
               data.repeater = repeater;
               repeater.append(this.templates.create(repeater.dataset.repeaterId, data));
               let form = this.getForm(repeater);
               this.initializeFields(repeater, form);
               this.a11y.announce('Row added');
            }
            removeRepeaterRow(row) {
               let repeater = row.closest('[data-repeater-id]');
               row.remove();
               this.reindexList(repeater);
               this.a11y.announce('Row removed');
            }
         checkForTagLists(form) {
            form.querySelectorAll(this.selectors.tagList.tagList)?.forEach(field=> {
               let config = {
                  id: field.querySelector('template').className??window.generateID('tagList'),
                  ui: window.uiFromSelectors(this.selectors.tagList, field),
                  element: field,
                  form: form.dataset.formId,
                  format: field.dataset.tagFormat??'first_field'
               };
               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(
                  template.className,
                  {
                     refs: {
                        label: this.selectors.tagList.label,
                     },
                     manyRefs: {
                        inputs: this.inputSelectors,
                     },
                     setup({el, refs, manyRefs, data}) {
                        let index = config.ui.items?.children?.length??0;
                        el.dataset.index = index;
                        manyRefs.inputs?.forEach(input => {
                           let wrapper = input.closest('.tag-item');
                           window.prefixInput(input, `${data.fieldName}:${index}:`, wrapper)
                        });
   initializeFields(container, config = null) {
      const fieldHandlers = {
         '[data-editor]': () => this.checkForQuill(container,config),
         'div.quantity': () => this.checkForQuantity(container),
         '.repeater': () => this.checkForRepeaters(container, config),
         '.field.tag-list': () => this.checkForTagLists(container),
         '[data-depends-on]': () => this.checkForConditionalFields(container),
         '[data-limit]': () => this.checkForCharacterLimits(container),
         '[data-uploader],[data-upload-field]': () => this.checkForImageUploads(container, config),
         'nav.tabs': () => this.checkForTabs(container, config),
         '[data-type="selector"]': () => this.checkForSelectors(container)
      };
                        if (refs.label) {
                           refs.label.textContent = data.label;
                        }
                     }
                  },
               );
               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);
            });
      for (const [selector, handler] of Object.entries(fieldHandlers)) {
         if (container.querySelector(selector)) {
            handler();
         }
            addTagListListeners(el) {
               el.addEventListener('click', this.tagListClick);
               el.addEventListener('keypress', this.tagListInput);
            }
            removeTagListListeners(el) {
               el.removeEventListener('click', this.tagListClick);
               el.removeEventListener('keypress', this.tagListInput);
            }
      }
            handleTagListClick(e) {
               if (window.targetCheck(e,this.selectors.tagList.add)) {
                  this.addTagListItem(e.target.closest('[data-tag-list-id]'));
               } else if (window.targetCheck(e, this.selectors.tagList.remove)) {
                  this.removeTagListItem(e.target.closest(this.selectors.tagList.item));
      let inputs = Array.from(container.querySelectorAll(this.inputSelectors))
         .filter(input => !input.closest('.ql-clipboard'));
      inputs.map(input => {
         this.getItem(input, config?.id);
      });
   }
   checkForQuill(form, config) {
      if (!form.querySelector('[data-editor]')) return;
      if (config && !Object.hasOwn(config, 'hasQuill')){
         config.hasQuill = true;
         this.forms.set(config.id, config);
      }
      if (!this.quillInstances.has(config.id)) {
         this.quillInstances.set(config.id, new Set());
      }
      const instances = window.jvbQuill(form);
      instances.forEach(instance => {
         this.quillInstances.get(config.id).add(instance);
      });
   }
   checkForQuantity(form) {
      if (!form.querySelector(this.selectors.number.number)) return;
      form.querySelectorAll(this.selectors.number.number).forEach(num => {
         let config = {
            id: window.generateID('quant'),
            form: form.dataset.formId,
            ui: window.uiFromSelectors(this.selectors.number, num),
            element: num
         };
         num.dataset.numId = config.id;
         this.quantityFields.set(config.id, config);
         this.addQuantityListeners(num);
      });
   }
   addQuantityListeners(el) {
      el.addEventListener('click', this.quantityClick);
   }
   removeQuantityListeners(el) {
      el.removeEventListener('click', this.quantityClick);
   }
   handleQuantityClick(e) {
      let conf = this.quantityFields.get(e.target.closest('[data-num-id]')?.dataset.numId);
      if(!conf) return;
      let change = 0;
      if (conf.ui.increase.contains(e.target)) {
         change++;
      } else if (conf.ui.decrease.contains(e.target)) {
         change--;
      }
      if (change === 0) return;
      let field = this.getField(e.target);
      let step = conf.ui.input.step;
      step = Math.max(step, 1);
      if (e.ctrlKey && e.shiftKey) {
         step = step * 50;
      } else if (e.ctrlKey) {
         step = step *5;
      } else if (e.shiftKey) {
         step = step * 10;
      }
      let value = (conf.ui.input.value === '') ? 0 : parseFloat(conf.ui.input.value);
      conf.ui.input.value = (value + (step * change));
      value = parseFloat(conf.ui.input.value);
      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.ui.decrease.disabled) conf.ui.decrease.disabled = false;
         if (conf.ui.increase.disabled) conf.ui.increase.disabled = false;
      }
   }
   checkForRepeaters(form) {
      if (!form.querySelector(this.selectors.repeater.repeater)) return;
      form.querySelectorAll(this.selectors.repeater.repeater).forEach(repeater => {
         let config = {
            id: repeater.querySelector('template').className??window.generateID('repeater'),
            ui: window.uiFromSelectors(this.selectors.repeater, repeater),
            form: form.dataset.formId,
            element: repeater,
            field: this.getField(repeater),
            sortable: false,
            rows: []
         };
         if (!config.ui.add) return;
         let template = repeater.querySelector('template');
         this.templates.define(
            template.className,
            {
               manyRefs: {
                  inputs: this.inputSelectors,
               },
               setup({el, refs, manyRefs, data}) {
                  let index = config.ui.items?.children?.length??0;
                  el.dataset.index = index;
                  manyRefs.inputs?.forEach(input => {
                     window.prefixInput(input, `${data.repeater.dataset.field}:${index}:`, el, false, true);
                  });
               }
            }
         addTagListItem(tagList) {
            let config = this.tagLists.get(tagList.dataset.tagListId);
            if (!config) return;
            },
         );
            let data = {};
            let hasValue = false;
            let isValid = true;
            // 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;
         if (window.Sortable) {
            config.sortable = new Sortable(repeater, {
               handle: this.selectors.repeater.header,
               animation: 150,
               onEnd: () => {
                  this.reindexList(repeater);
               }
               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);
                     }
                  } else {
                     label = data[config.format]??Object.values(data)[0];
                  }
                  break;
            }
            let newItem = this.templates.create(tagList.dataset.tagListId, {
               label: label,
               fieldName: config.fieldName
            });
            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] || '';
            });
            config.ui.items.append(newItem);
            // Clear inputs AFTER success
            for (let input of config.ui.inputs) {
               if (['checkbox', 'radio'].includes(input.type)) {
                  input.checked = false;
               } else {
                  input.value = '';
               }
               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]');
               if (!field) return;
               let config = this.tagLists.get(field.dataset.tagListId);
               if (!config) return;
               if (e.key === 'Enter') {
                  if (target === config.ui.inputs[config.ui.inputs.length - 1]) {
                     e.preventDefault();
                     this.addTagListItem(target.closest('[data-tag-list-id]'));
                  } else {
                     e.preventDefault();
                     let index = config.ui.inputs.indexOf(target);
                     config.ui.inputs[index+1].focus();
                  }
               }
            }
         checkForConditionalFields(form) {
            form.querySelectorAll(this.selectors.dependsOn).forEach( field => {
               const dependsOn = field.dataset.dependsOn;
               const requiredValue = field.dataset.dependsValue;
               const operator = field.dataset.dependsOperatior??'==';
               if (!this.dependencies.has(dependsOn)) {
                  let element = document.querySelector(`[field="${dependsOn}"]`);
                  if (element) {
                     this.dependencies.set(dependsOn, {
                        element: element,
                        items: []
                     });
                  }
               }
               let dependency = this.dependencies.get(dependsOn);
               dependency.items.push({
                  field: field,
                  form: form.dataset.formId,
                  requiredValue: requiredValue,
                  operator: operator
               });
               this.dependencies.set(dependsOn, dependency);
               this.checkFieldDependency(dependency, dependsOn);
            });
         }
            checkFieldDependency(dependentField, controlFieldName) {
               const controlField = this.dependencies.get(controlFieldName);
               if (!controlField) return;
               const controlValue = this.getFieldCheckedValue(controlField.element);
               const shouldShow = this.evaluateCondition(
                  controlValue,
                  dependentField.requiredValue,
                  dependentField.operator
               );
         repeater.dataset.repeaterId = config.id;
         this.addRepeaterListeners(repeater);
         this.repeaters.set(config.id, config);
      });
               this.toggleFieldVisibility(dependentField.field, shouldShow);
            }
            evaluateCondition(value, requiredValue, operator) {
               const fieldStr = String(value || '');
               const requiredStr = String(requiredValue || '');
   }
   addRepeaterListeners(el) {
      el.addEventListener('click', this.repeaterClick);
   }
   removeRepeaterListeners(el) {
      el.removeEventListener('click', this.repeaterClick);
   }
   handleRepeaterClick(e) {
      if (e.target.matches(this.selectors.repeater.add)) {
         this.addRepeaterRow(e.target.closest('[data-repeater-id]'));
      } else if (e.target.matches(this.selectors.repeater.remove)) {
         this.removeRepeaterRow(e.target.closest('[data-index]'));
      }
   }
   addRepeaterRow(repeater) {
      let data = {};
      data.repeater = repeater;
      let config = this.repeaters.get(repeater.dataset.repeaterId);
               switch (operator) {
                  case '==': return fieldStr === requiredStr;
                  case '!=': return fieldStr !== requiredStr;
                  case '>': return parseFloat(fieldStr) > parseFloat(requiredStr);
                  case '<': return parseFloat(fieldStr) < parseFloat(requiredStr);
                  case '>=': return parseFloat(fieldStr) >= parseFloat(requiredStr);
                  case '<=': return parseFloat(fieldStr) <= parseFloat(requiredStr);
                  case 'contains': return fieldStr.includes(requiredStr);
                  case 'empty': return fieldStr === '';
                  case 'not_empty': return fieldStr !== '';
                  default: return fieldStr === requiredStr;
               }
            }
            toggleFieldVisibility(field, show) {
               const wrapper = field.closest('.field, fieldset');
               if (!wrapper) return;
      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);
               wrapper.hidden = !show;
               wrapper.querySelectorAll('input, select, textarea').forEach(control => {
                  control.disabled = !show;
                  if (!show && control.hasAttribute('required')) {
                     control.dataset.wasRequired = 'true';
                     control.removeAttribute('required');
                  } else if (show && control.dataset.wasRequired === 'true') {
                     control.setAttribute('required', '');
                     delete control.dataset.wasRequired;
      let form = this.getForm(repeater);
      this.initializeFields(repeater, form);
      this.a11y.announce('Row added');
   }
   removeRepeaterRow(row) {
      let repeater = row.closest('[data-repeater-id]');
      row.remove();
      this.reindexList(repeater);
      this.a11y.announce('Row removed');
   }
   checkForTagLists(form) {
      form.querySelectorAll(this.selectors.tagList.tagList)?.forEach(field=> {
         let config = {
            id: field.querySelector('template').className??window.generateID('tagList'),
            ui: window.uiFromSelectors(this.selectors.tagList, field),
            element: field,
            form: form.dataset.formId,
            format: field.dataset.tagFormat??'first_field'
         };
         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(
            template.className,
            {
               refs: {
                  label: this.selectors.tagList.label,
               },
               manyRefs: {
                  inputs: this.inputSelectors,
               },
               setup({el, refs, manyRefs, data}) {
                  let index = config.ui.items?.children?.length??0;
                  el.dataset.index = index;
                  manyRefs.inputs?.forEach(input => {
                     let wrapper = input.closest('.tag-item');
                     window.prefixInput(input, `${data.fieldName}:${index}:`, wrapper, false, true)
                  });
                  if (refs.label) {
                     refs.label.textContent = data.label;
                  }
               }
            },
         );
         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);
      });
   }
   addTagListListeners(el) {
      el.addEventListener('click', this.tagListClick);
      el.addEventListener('keypress', this.tagListInput);
   }
   removeTagListListeners(el) {
      el.removeEventListener('click', this.tagListClick);
      el.removeEventListener('keypress', this.tagListInput);
   }
   handleTagListClick(e) {
      if (window.targetCheck(e,this.selectors.tagList.add)) {
         this.addTagListItem(e.target.closest('[data-tag-list-id]'));
      } 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;
      let data = {};
      let hasValue = false;
      let isValid = true;
      // 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);
               }
            } else {
               label = data[config.format]??Object.values(data)[0];
            }
            break;
      }
      let newItem = this.templates.create(tagList.dataset.tagListId, {
         label: label,
         fieldName: config.fieldName
      });
      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] || '';
      });
      config.ui.items.append(newItem);
      // Clear inputs AFTER success
      for (let input of config.ui.inputs) {
         if (['checkbox', 'radio'].includes(input.type)) {
            input.checked = false;
         } else {
            input.value = '';
         }
         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]');
      if (!field) return;
      let config = this.tagLists.get(field.dataset.tagListId);
      if (!config) return;
      if (e.key === 'Enter') {
         if (target === config.ui.inputs[config.ui.inputs.length - 1]) {
            e.preventDefault();
            this.addTagListItem(target.closest('[data-tag-list-id]'));
         } else {
            e.preventDefault();
            let index = config.ui.inputs.indexOf(target);
            config.ui.inputs[index+1].focus();
         }
      }
   }
   checkForConditionalFields(form) {
      form.querySelectorAll(this.selectors.dependsOn).forEach( field => {
         const dependsOn = field.dataset.dependsOn;
         const requiredValue = field.dataset.dependsValue;
         const operator = field.dataset.dependsOperatior??'==';
         if (!this.dependencies.has(dependsOn)) {
            let element = document.querySelector(`[field="${dependsOn}"]`);
            if (element) {
               this.dependencies.set(dependsOn, {
                  element: element,
                  items: []
               });
            }
         }
         let dependency = this.dependencies.get(dependsOn);
         dependency.items.push({
            field: field,
            form: form.dataset.formId,
            requiredValue: requiredValue,
            operator: operator
         });
         this.dependencies.set(dependsOn, dependency);
         this.checkFieldDependency(dependency, dependsOn);
      });
   }
   checkFieldDependency(dependentField, controlFieldName) {
      const controlField = this.dependencies.get(controlFieldName);
      if (!controlField) return;
      const controlValue = this.getFieldCheckedValue(controlField.element);
      const shouldShow = this.evaluateCondition(
         controlValue,
         dependentField.requiredValue,
         dependentField.operator
      );
      this.toggleFieldVisibility(dependentField.field, shouldShow);
   }
   evaluateCondition(value, requiredValue, operator) {
      const fieldStr = String(value || '');
      const requiredStr = String(requiredValue || '');
      switch (operator) {
         case '==': return fieldStr === requiredStr;
         case '!=': return fieldStr !== requiredStr;
         case '>': return parseFloat(fieldStr) > parseFloat(requiredStr);
         case '<': return parseFloat(fieldStr) < parseFloat(requiredStr);
         case '>=': return parseFloat(fieldStr) >= parseFloat(requiredStr);
         case '<=': return parseFloat(fieldStr) <= parseFloat(requiredStr);
         case 'contains': return fieldStr.includes(requiredStr);
         case 'empty': return fieldStr === '';
         case 'not_empty': return fieldStr !== '';
         default: return fieldStr === requiredStr;
      }
   }
   toggleFieldVisibility(field, show) {
      const wrapper = field.closest('.field, fieldset');
      if (!wrapper) return;
      wrapper.hidden = !show;
      wrapper.querySelectorAll('input, select, textarea').forEach(control => {
         control.disabled = !show;
         if (!show && control.hasAttribute('required')) {
            control.dataset.wasRequired = 'true';
            control.removeAttribute('required');
         } else if (show && control.dataset.wasRequired === 'true') {
            control.setAttribute('required', '');
            delete control.dataset.wasRequired;
         }
      });
   }
   checkForCharacterLimits(form) {
      if (!form.querySelector(this.selectors.limits.hasLimit)) return;
      this.countUpdaters = this.updateCount.bind(this);
      form.querySelectorAll(this.selectors.limits.hasLimit).forEach(field => {
         const input = field.querySelector('input, textarea, select');
         const input = this.getFieldInput(field);
         if (!input) return;
         let id = window.generateID('limit');
@@ -1157,84 +1239,92 @@
         this.addCharacterLimitListeners(input);
      });
   }
            addCharacterLimitListeners(input) {
               input.addEventListener('input', this.countUpdaters, {passive: true});
   addCharacterLimitListeners(input) {
      input.addEventListener('input', this.countUpdaters, {passive: true});
   }
   removeCharacterLimitListeners(input) {
      input.removeEventListener('input', this.countUpdaters, {passive: true});
   }
   updateCount(e) {
      let target = e.target;
      let config = this.charLimits.get(target.dataset.charLimitId);
      if (!config) return;
      let length = target.value.length;
      let limit = target.dataset.limit;
      if (config.ui.current) {
         config.ui.current.textContent = length;
         config.ui.current.classList.toggle('exceeded', length >= limit);
      }
      if (length > limit) {
         target.value = target.value.slice(0, limit);
      }
   }
   checkForImageUploads(form, config) {
      this.hasUploads = true;
      window.jvbUploads.scanFields(form, config.options.autoUpload, config.options.imageMeta);
      let uploads = form.querySelectorAll('[data-field-type="upload"]');
      if (uploads) {
         config.ui.uploads = {};
         uploads.forEach(upload => {
            config.ui.uploads[upload.dataset.field] = upload;
         });
      }
   }
   checkForTabs(form, config) {
      if (window.jvbTabs && form.querySelector('nav.tabs')) {
         config.tabs = window.jvbTabs.registerTab(form, {
            preCheck: (section, tabConfig) => {
               return this.validateStep(section, config);
            }
            removeCharacterLimitListeners(input) {
               input.removeEventListener('input', this.countUpdaters, {passive: true});
            }
            updateCount(e) {
               let target = e.target;
               let config = this.charLimits.get(target.dataset.charLimitId);
               if (!config) return;
               let length = target.value.length;
               let limit = target.dataset.limit;
               if (config.ui.current) {
                  config.ui.current.textContent = length;
                  config.ui.current.classList.toggle('exceeded', length >= limit);
               }
               if (length > limit) {
                  target.value = target.value.slice(0, limit);
         });
         config.ui.tabs = window.uiFromSelectors(this.selectors.tabs, form);
         config.ui.tabs.sections = Array.from(form.querySelectorAll(this.selectors.tabs.sections));
         config.ui.tabs.inputs = {};
         config.ui.tabs.sections.forEach(section => {
            config.ui.tabs.inputs[section.dataset.tab] = Array.from(section.querySelectorAll(this.inputs));
         });
         config.ui.tabs.buttons = Array.from(form.querySelectorAll(this.selectors.tabs.buttons));
         config.unsubscribeTabs = window.jvbTabs.subscribe((event, data) => {
            if (event === 'tab-switched') {
               if (config.ui.tabs.progress) {
                  const section = config.ui.tabs.sections.filter(section => section.dataset.tab === data.current)[0]??false;
                  if (!section) return;
                  const step = section.dataset.step;
                  const total = config.ui.sections.length;
                  window.showProgress(
                     config.ui.tabs.progress,
                     step,
                     total
                  );
               }
            }
         checkForImageUploads(form, config) {
            window.jvbUploads.scanFields(form, config.options.autoUpload, config.options.imageMeta);
         }
         });
         this.forms.set(config.id, config);
      }
   }
   validateStep(section, config) {
      const formId = section.closest('[data-form-id]')?.dataset.formId;
      if (!formId) return true;
         checkForTabs(form, config) {
            if (window.jvbTabs && form.querySelector('nav.tabs')) {
               config.tabs = window.jvbTabs.registerTab(form, {
                  preCheck: (section, tabConfig) => {
                     return this.validateStep(section, config);
                  }
               });
               config.ui.tabs = window.uiFromSelectors(this.selectors.tabs, form);
               config.ui.tabs.sections = Array.from(form.querySelectorAll(this.selectors.tabs.sections));
               config.ui.tabs.inputs = {};
               config.ui.tabs.sections.forEach(section => {
                  config.ui.tabs.inputs[section.dataset.tab] = Array.from(section.querySelectorAll(this.inputs));
               });
               config.ui.tabs.buttons = Array.from(form.querySelectorAll(this.selectors.tabs.buttons));
      const form = this.forms.get(formId);
      if (!form) return true;
               config.unsubscribeTabs = window.jvbTabs.subscribe((event, data) => {
                  if (event === 'tab-switched') {
                     if (config.ui.tabs.progress) {
                        const section = config.ui.tabs.sections.filter(section => section.dataset.tab === data.current)[0]??false;
                        if (!section) return;
                        const step = section.dataset.step;
                        const total = config.ui.sections.length;
      const inputs = Array.from(this.inputs.values())
         .filter(item =>
            item &&
            item.form === formId &&
            item.section === section.dataset.tab &&
            !item.element.closest('[hidden]')
         );
                        window.showProgress(
                           config.ui.tabs.progress,
                           step,
                           total
                        );
                     }
                  }
               });
               this.forms.set(config.id, config);
            }
         }
         validateStep(section, config) {
            const formId = section.closest('[data-form-id]')?.dataset.formId;
            if (!formId) return true;
            const form = this.forms.get(formId);
            if (!form) return true;
            const inputs = Array.from(this.inputs.values())
               .filter(item =>
                  item &&
                  item.form === formId &&
                  item.section === section.dataset.tab &&
                  !item.element.closest('[hidden]')
               );
            return inputs.every(item => this.validateField(item.element) === true);
         }
         checkForSelectors(form) {
            if (window.jvbSelector) window.jvbSelector.scanExistingFields(form);
         }
      return inputs.every(item => this.validateField(item.element) === true);
   }
   checkForSelectors(form) {
      if (window.jvbSelector) window.jvbSelector.scanExistingFields(form);
   }
   /**
    * Mainly for repeaters or taglist
    * @param {HTMLElement} container
@@ -1259,7 +1349,9 @@
            window.prefixInput(
               input,
               `${fieldName}:${index}:`,
               item  // Pass the item as wrapper for label lookup
               item,
               false,
               true
            );
         });
      });
@@ -1288,7 +1380,7 @@
   }
   /**********************************************************************
    VALIDATION
   **********************************************************************/
    **********************************************************************/
   //text, email, url, tel, date, time, datetime, number
   //select, checkbox, radio, true_false
   //textarea
@@ -1323,8 +1415,6 @@
      field.classList.remove('has-success');
      field.classList.add('has-error');
      if (item.ui.success) item.ui.success.hidden = true;
      if (item.ui.error) item.ui.error.hidden = true;
      if (item.ui.message) {
         item.ui.message.hidden = false;
         item.ui.message.textContent = message;
@@ -1340,9 +1430,6 @@
      field.classList.remove('has-error');
      field.classList.add('has-success');
      if (item.ui.success) item.ui.success.hidden = false;
      if (item.ui.error) item.ui.error.hidden = true;
      if (item.ui.message) {
         item.ui.message.hidden = message=== '';
         item.ui.message.textContent = message;
@@ -1490,35 +1577,35 @@
      form.ui.status.icon.className = 'icon icon-'+this.getDefaultIcon(status);
      setTimeout(()=> form.ui.status.status.hidden = true, (status === 'submitted') ? 3000 : 10000);
   }
      getDefaultMessage(status) {
         const messages = {
            'saving': 'Saving changes...',
            'autosaved': 'Changes saved locally. Submit form to send to server.',
            'uploading': 'Uploading your form to server',
            'submitted': 'Successfully sent to server',
            'pending': 'Unsaved changes',
            'restored': 'Welcome back! We\'ve restored your previous entry.',
            'error': 'Failed to save changes. Refresh and try again?',
            'offline': 'Changes will be saved when online'
         };
         return messages[status]??status;
   getDefaultMessage(status) {
      const messages = {
         'saving': 'Saving changes...',
         'autosaved': 'Changes saved locally. Submit form to send to server.',
         'uploading': 'Uploading your form to server',
         'submitted': 'Successfully sent to server',
         'pending': 'Unsaved changes',
         'restored': 'Welcome back! We\'ve restored your previous entry.',
         'error': 'Failed to save changes. Refresh and try again?',
         'offline': 'Changes will be saved when online'
      };
      return messages[status]??status;
   }
   getDefaultIcon(status) {
      const icons = {
         'autosaved': 'check-circle',
         'submitted': 'check-circle',
         'restored': 'history',
         'error': 'close-circle',
         'offline': 'cloud-slash',
         'pending': 'exclamation-mark'
      }
      getDefaultIcon(status) {
         const icons = {
            'autosaved': 'check-circle',
            'submitted': 'check-circle',
            'restored': 'history',
            'error': 'close-circle',
            'offline': 'cloud-slash',
            'pending': 'exclamation-mark'
         }
         return icons[status]??'';
      }
      return icons[status]??'';
   }
   /**********************************************************************
    SUMMARY
   **********************************************************************/
    **********************************************************************/
   showSummary(data) {
      let summary = this.templates.create('formSummary', data);
      data.config.element.after(summary);
@@ -1526,7 +1613,7 @@
   }
   /**********************************************************************
    UTILITY
   **********************************************************************/
    **********************************************************************/
   getForm(element) {
      let form = element.closest('[data-form-id]');
      if (!form) return false;
@@ -1547,6 +1634,7 @@
   getFieldValue(element) {
      let type = this.getFieldType(element);
      let conf = this.getItem(element);
      let fieldName = conf.field?.dataset.field??false;
      if (!fieldName) return false;
@@ -1558,18 +1646,21 @@
            return this.getTagListValue(element, conf);
         case 'group':
            //Do we actually need anything here? I think each subfield just
            break;
            return null;
         //Do we actually need anything here? I think each subfield just
         case 'location':
            return this.getLocationValue(element, conf);
         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('[]')) {
@@ -1622,55 +1713,73 @@
      if (Array.isArray(value) && value.length === 0) return true;
      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);
         }
         let value = [];
         Array.from(conf.container.children).forEach(row => {
            let rowData = {};
            row.querySelectorAll('[data-field]').forEach(field => {
               rowData[field.dataset.field] = this.getFieldValue(field);
            });
            value.push(rowData);
   getRepeaterValue(element, 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(items.children).forEach(row => {
         let rowData = {};
         row.querySelectorAll('[data-field]').forEach(field => {
            if (!ignore.includes(field.dataset.field)) {
               const input = this.getFieldInput(field);
               if (input) {
                  rowData[field.dataset.field] = this.getFieldValue(input);
               }
            }
         });
         return value;
         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');
         this.saveItem(conf);
      }
      getTagListValue(element, conf) {
         if (!conf.container) {
            conf.container = conf.field?.querySelector('.tag-items');
            this.saveItem(conf);
         }
         let value = [];
         Array.from(conf.container.children).forEach(item => {
            let inputs = item.querySelectorAll('input[type="hidden"]');
            let fieldData = {};
            inputs.forEach(input => {
               fieldData[input.dataset.field] = input.value;
            });
            value.push(fieldData);
      let value = [];
      Array.from(conf.container.children).forEach(item => {
         let inputs = item.querySelectorAll('input[type="hidden"]');
         let fieldData = {};
         inputs.forEach(input => {
            fieldData[input.dataset.field] = input.value;
         });
         return value;
         value.push(fieldData);
      });
      return value;
   }
   getLocationValue(element, conf) {
      if(!conf.values){
         conf.values = Array.from(conf.field?.querySelectorAll('[data-location-field]'));
         this.saveItem(conf);
      }
      getLocationValue(element, conf) {
         if(!conf.values){
            conf.values = Array.from(conf.field?.querySelectorAll('[data-location-field]'));
            this.saveItem(conf);
      let value = {};
      conf.values.forEach(input => {
         value[input.dataset.locationField] = input.value;
      });
      return value;
   }
   getHiddenInputValue(element, conf, fieldName) {
      if (element.tagName !== 'INPUT' || element.type !== 'hidden'){
         element = element.querySelector('input[type="hidden"][name="'+fieldName+'"]');
         if (!element) {
            return null;
         }
         let value = {};
         conf.values.forEach(input => {
            value[input.dataset.locationField] = input.value;
         });
         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;
      if (conf.value === undefined || conf.value !== element.value) {
         conf.value = element.value;
         this.saveItem(conf);
      }
      return conf.value;
   }
   /**
    * Format field value for display in summary
@@ -1710,7 +1819,9 @@
         case 'selector':
         case 'upload':
            // These might need special handling depending on your needs
         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);
         default:
@@ -1832,7 +1943,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) {
@@ -1930,7 +2041,7 @@
   }
   /**********************************************************************
    Subscription
   **********************************************************************/
    **********************************************************************/
   subscribe(callback) {
      this.subscribers.add(callback);
      return () => this.subscribers.delete(callback);
@@ -1947,7 +2058,7 @@
   }
   /**********************************************************************
    Cleanup
   **********************************************************************/
    **********************************************************************/
   destroy() {
      if (this.forms.size > 0) {
         Array.from(this.forms.values()).forEach(form => {