class FormController { constructor() { this.a11y = window.jvbA11y; this.error = window.jvbError; this.queue = window.jvbQueue; this.populate = window.jvbPopulate; this.changes = new Map(); this.forms = new Map(); this.inputs = new Map(); this.repeaters = new Map(); this.tagLists = new Map(); this.charLimits = new Map(); this.quantityFields = new Map(); this.quillInstances = new Map(); // formId -> Set of quill instances this.dependencies = new Map(); this.subscribers = new Set(); this.isRestoring = false; this.hasListeners = false; this.summaryTemplate = false; this.init(); } init() { this.templates = window.jvbTemplates; this.defineSummaryTemplate(); this.initElements(); this.initListeners(); this.initStore(); this.initValidators(); } initElements() { this.inputSelectors = 'input, textarea, select'; this.selectors = { tabs: { nav: 'nav.tabs', sections: '.tab.content', //querySelectorAll progress: { progress: '.progress', fill: '.progress .fill', details: '.progress .details', icon: '.progress .icon' }, buttons: 'nav.tabs button', }, dependsOn: '[data-depends-on]', forms: { status: { status: '.fstatus', message: '.fstatus .message', icon: '.fstatus .icon', actions: '.fstatus .actions' } }, inputs: this.inputSelectors, //querySelectorAll fields: { field: '.field', //querySelectorAll label: 'label', success: '.success', error: '.error', message: '.validation-message', }, repeater: { repeater: '.repeater', //querySelectorAll header: '.repeater-row-header', remove: '.remove-row', add: '.add-repeater-row', template: 'template', items: '.repeater-items', inputs: this.inputSelectors //querySelectorAll }, tagList: { tagList: '.field.tag-list', //querySelectorAll input: '.row', add: '.add-tag', remove: '.remove-tag', label: '.tag-label', items: '.tag-items', item: '.tag-item', inputs: this.inputSelectors, //querySelectorAll value: 'input[type="hidden"]' //querySelectorAll }, tag: { label: '.tag-label' }, number: { number: '.field div.quantity', increase: 'button.increase', decrease: 'button.decrease', input: 'input[type="number"]' }, limits: { hasLimit: '[data-maxlength]', limit: '.limit', current: '.current', } }; } initListeners() { this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); this.blurHandler = this.handleBlur.bind(this); this.inputHandler = this.handleInput.bind(this); this.submitHandler = this.handleSubmit.bind(this); this.quantityClick = this.handleQuantityClick.bind(this); this.repeaterClick = this.handleRepeaterClick.bind(this); 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); } initStore() { const store = window.jvbStore.register( 'forms', { storeName: 'forms', keyPath: 'id', indexes: [ { name: 'src', keyPath: 'src'}, { name: 'timestamp', keyPath: 'timestamp' }, { name: 'formType', keyPath: 'type' } ], TTL: 7 * 24 * 60 * 1000, //7 days }); this.store = store.forms; this.store.subscribe((event, data)=> { if (event === 'data-ready') { let stored = this.store.getFiltered(); let pending = stored.filter(form=> form.src === window.location.pathname); for (let form of pending) { this.showPendingNotification(form.id, form.changes); } } else if (event === 'operation-status' && data.status === 'completed') { if (data.config) { this.store.delete(data.config.id); } } }); } 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; } const notification = document.createElement('div'); notification.className = 'pendingChanges'; notification.innerHTML = `

We noticed unsaved changes from last time. Would you like to restore them?

`; 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 = { email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Please enter a valid email address' }, url: { pattern: /^https?:\/\/.+\..+/, message: 'Please enter a valid URL starting with https://' }, phone: { pattern: /^[\d\s\-+().]+$/, message: 'Please enter a valid phone number' }, number: { test: (value, fieldWrapper) => { const num = parseFloat(value); if (isNaN(num)) return 'Please enter a valid number'; const min = fieldWrapper.dataset.min; const max = fieldWrapper.dataset.max; if (min !== undefined && num < parseFloat(min)) { return `Value must be at least ${min}`; } if (max !== undefined && num > parseFloat(max)) { return `Value must be at most ${max}`; } return true; } }, text: { test: (value, fieldWrapper) => { const minLength = fieldWrapper.dataset.minlength; const maxLength = fieldWrapper.dataset.maxlength; if (minLength && value.length < parseInt(minLength)) { return `Must be at least ${minLength} characters`; } if (maxLength && value.length > parseInt(maxLength)) { return `Must be no more than ${maxLength} characters`; } return true; } } }; } validateField(input) { const result = this.performValidation(input); this.updateValidationUI(input, result); return result.isValid; } performValidation(input) { const field = input.closest('.field'); const value = this.getFieldCheckedValue(input); if (!value && !input.required) { return { isValid: true, message: '' }; } if (input.required) { if (input.type === 'checkbox') { if (!input.checked) { return { isValid: false, message: 'This field is required' }; } } else if (input.type === 'radio') { const radioGroup = document.querySelectorAll(`input[name="${input.name}"]`); const anyChecked = Array.from(radioGroup).some(r => r.checked); if (!anyChecked) { return { isValid: false, message: 'Please select an option' }; } } else if (!value) { return { isValid: false, message: 'This field is required' }; } } if(input.checkValidity && !input.checkValidity()){ return {isValid: false, message: input.validationMessage}; } if (value && Object.hasOwn(field.dataset, 'pattern')) { const regex = new RegExp(field.dataset.pattern); if (!regex.test(value)) { return {isValid: false, message: field.dataset.validationMessage || 'Invalid format'}; } } if (Object.hasOwn(field.dataset, 'validate') || input.type) { const validator = this.validators[field.dataset.validate||input.type]; if (validator && validator.pattern && !validator.pattern.test(value)) { return {isValid: false, message: validator.message}; } if (validator && validator.test) { const result = validator.test(value, field); if (result !== true) { return {isValid: false, message: result}; } } } return {isValid: true, message: ''}; } updateValidationUI(input, result) { if (result.isValid) { this.showSuccess(input, result.message); } else { this.showError(input, result.message); } } handleClick(e) { let form = this.getForm(e.target); if (!form) return; const itemAction = window.targetCheck(e, '[data-action]'); if (itemAction) { let action = itemAction.dataset.action; switch (action) { case 'clear-form': this.store.delete(form.id); form.element.reset(); form.ui.status.status.hidden = true; this.a11y.announce('Form cleared, starting fresh'); break; case 'dismiss-restore': form.ui.status.status.hidden = true; break; } } } handleChange(e) { if (e.target.closest('[data-ignore]') || this.isRestoring) return; 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); dependency.items.forEach(item => { this.checkFieldDependency(item, field.dataset.field); }); } let form = this.getForm(e.target); this.updateItem(field.dataset.field, this.getFieldValue(e.target), form); } handleBlur(e) { if (e.target.closest('[data-ignore]') || this.isRestoring) return; let form = this.getForm(e.target); if (!form) return; let field = this.getField(e.target); let fieldName = field.dataset.field; 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; let field = this.getField(e.target); if (!field) return; const input = e.target; // Capture reference const fieldName = field.dataset.field; // Show pending status regardless of cache this.showFormStatus(form.id, 'pending'); // Debounce validation window.debouncer.schedule( `form:${form.id}:validate:${fieldName}`, () => this.validateField(input), 500 ); } async handleSubmit(e) { let form = this.getForm(e.target); if (!form) return; if (this.subscribers.size > 0) { e.preventDefault(); if (form.options.cache) { this.cancelBackup(); await this.backup(); const storedData = await this.store.get(form.id); this.notify('form-submit', { config: form, data: storedData.changes }); } else { this.notify('form-submit', { config: form, data: this.changes.get(form.id)?.changes??{}, }); } } if (form.options.showSummary) { const storedData = await this.store.get(form.id); this.showSummary({config: form, changes: storedData?.changes}); } } /** * Updates the item, schedules caching if * @param name * @param value * @param form */ updateItem(name, value, form) { if (!this.changes.has(form.id)) { this.changes.set(form.id, { id: form.id, timestamp: Date.now(), src: window.location.pathname, changes: {}, }); } let changes = this.changes.get(form.id); changes.changes[name] = value; this.changes.set(form.id, changes); if (form.options.cache) { this.scheduleBackup(); } } scheduleBackup() { window.debouncer.schedule( `form_changes`, async () => { if (this.changes.size > 0) { await this.backup(); } }, 2000 ); } cancelBackup() { window.debouncer.cancel('form_changes'); } async backup() { // Merge with existing stored data const toSave = new Map(); for (let [formId, newData] of this.changes.entries()) { const stored = await this.store.get(formId); if (stored) { // Merge changes toSave.set(formId, { ...stored, ...newData, changes: { ...stored.changes, ...newData.changes }, timestamp: Date.now() }); } else { toSave.set(formId, newData); } } await this.store.saveMany(toSave); for (let formId of this.changes.keys()) { this.showFormStatus(formId, 'autosaved'); } this.changes.clear(); } saveCache(formId) { if (!this.changes.has(formId)) return; let changes = this.changes.get(formId); if (changes.size === 0) return; this.store.save(changes).then(()=>{}); this.changes.delete(formId); } /** * Register a form for handling * @param {HTMLElement} form * @param {object} options */ registerForm(form, options) { //Bail if form already registered if (Object.hasOwn(form.dataset, 'formId') && this.forms.has(form.dataset.formId)) return; if (!Object.hasOwn(form.dataset, 'formId')) { form.dataset.formId = window.generateID('form_'); } const formId = form.dataset.formId; this.addFormListeners(form); const config = { 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??[] }, ui: window.uiFromSelectors(this.selectors.forms, form) }; this.initializeFields(form, config); this.forms.set(formId, config); return config; } clearForm(formId) { const config = this.forms.get(formId); if (!config) return; 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); } } // 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 (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(); } // 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(); } }); 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.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) }; for (const [selector, handler] of Object.entries(fieldHandlers)) { if (container.querySelector(selector)) { handler(); } } 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); }); } }, ); if (window.Sortable) { config.sortable = new Sortable(repeater, { handle: this.selectors.repeater.header, animation: 150, onEnd: () => { this.reindexList(repeater); } }); } repeater.dataset.repeaterId = config.id; this.addRepeaterListeners(repeater); this.repeaters.set(config.id, config); }); } 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); 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) { 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 = this.getFieldInput(field); if (!input) return; 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}); } 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) { window.jvbUploads.scanFields(form, config.options.autoUpload, config.options.imageMeta); } 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)); 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 ); } } }); 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); } /** * Mainly for repeaters or taglist * @param {HTMLElement} container */ reindexList(container) { const fieldName = container.dataset.field || container.dataset.repeaterId || container.dataset.tagListId; Array.from(container.children).forEach((item, index) => { item.dataset.index = `${index}`; // Find ALL inputs within this item, not just direct children const inputs = item.querySelectorAll('input, select, textarea'); inputs.forEach(input => { // Skip inputs that shouldn't be re-indexed (like file inputs) if (input.type === 'file') return; // Get the field name from the input's data-field or name const inputField = input.dataset.field || input.name.split(':').pop(); // Re-prefix with the new index, passing item as wrapper window.prefixInput( input, `${fieldName}:${index}:`, item, false, true ); }); }); this.updateCollectionField(container); } /** * Update the entire repeater/tagList field data * Call this whenever rows are added, removed, or reordered */ updateCollectionField(element) { const field = element.closest('[data-field]'); if (!field) return; const fieldType = field.dataset.fieldType; if (!['repeater', 'tag-list'].includes(fieldType)) return; const form = this.getForm(element); if (!form) return; // Get all current data for the collection const value = this.getFieldValue(field); this.updateItem(field.dataset.field, value, form); } /********************************************************************** VALIDATION **********************************************************************/ //text, email, url, tel, date, time, datetime, number //select, checkbox, radio, true_false //textarea //repeater: subfields validation; no submission until all required are entered //tag fields: similar to repeater; each separate field is its own hidden field //upload: comma separated ints //selector: comma separated ints //location: hidden inputs for address, lat, lng, street, city, province, postal_code, country clearValidation(input) { let field = this.getField(input); if (!field) return; let item = this.getItem(input); if (!item) return; field.classList.remove('has-error', 'has-success'); 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 = true; item.ui.message.textContent = ''; } } showError(input, message = 'Invalid field') { let field = this.getField(input); if (!field) return; let item = this.getItem(input); if (!item) return; 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; } } showSuccess(input, message = '') { let field = this.getField(input); if (!field) return; let item = this.getItem(input); if (!item) return; 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; } } handleFormSuccess(form, data) { // Clear previous errors form.querySelectorAll('.error-message').forEach(el => el.remove()); form.querySelectorAll('.field-error').forEach(el => el.classList.remove('field-error') ); // Add success class to form form.classList.add('form-success'); // Show success message if provided if (data.message) { const success = document.createElement('div'); success.className = 'form-success-message success-message'; success.textContent = data.message; form.insertBefore(success, form.firstChild); const icon = window.getIcon?.('check-circle'); if (icon) { icon.classList.add('success-icon'); success.prepend(icon); } } // If there's a title/description (for registration success) if (data.title || data.description) { const successBox = document.createElement('div'); successBox.className = 'success-box'; if (data.title) { const title = document.createElement('h3'); title.textContent = data.title; successBox.appendChild(title); } if (data.description) { const descriptions = Array.isArray(data.description) ? data.description : [data.description]; descriptions.forEach(desc => { const p = document.createElement('p'); p.textContent = desc; successBox.appendChild(p); }); } form.insertBefore(successBox, form.firstChild); } // DELETE CACHED FORM DATA ON SUCCESS if (form.dataset.formId) { this.store.delete(form.dataset.formId).catch(err => { console.warn('Failed to clear form cache:', err); }); // Clear form config dirty state const formConfig = this.forms.get(form.dataset.formId); if (formConfig) { formConfig.isDirty = false; formConfig.lastSaved = Date.now(); formConfig.data = {}; // Clear cached data } } // Announce success for accessibility if (window.jvbA11y) { window.jvbA11y.announce(data.message || 'Form submitted successfully'); } } handleFormError(form, data) { // Clear all previous errors form.querySelectorAll('.error-message').forEach(el => el.remove()); form.querySelectorAll('.field-error, .has-error').forEach(el => { el.classList.remove('field-error', 'has-error'); }); // Clear validation states using existing method form.querySelectorAll('.field').forEach(fieldWrapper => { this.clearValidation(fieldWrapper); }); // Handle field-specific errors if (data.field) { const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`); if (fieldWrapper) { this.showError(fieldWrapper, data.message); fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' }); const input = fieldWrapper.querySelector('input, textarea, select'); if (input) { input.focus(); } } } else { const error = document.createElement('div'); error.className = 'form-error error-message'; error.textContent = data.message; const icon = window.getIcon?.('close-circle'); if (icon) { icon.classList.add('error-icon'); error.prepend(icon); } form.insertBefore(error, form.firstChild); form.scrollIntoView({ behavior: 'smooth', block: 'start' }); } if (window.jvbA11y) { const announcement = data.field ? `Error in ${data.field}: ${data.message}` : `Form error: ${data.message}`; window.jvbA11y.announce(announcement); } form.dispatchEvent(new CustomEvent('jvb-form-error', { detail: data })); } /********************************************************************** STATUS **********************************************************************/ showFormStatus(formId, status, message ='') { let form = this.forms.get(formId); if (!form || !form.options.showStatus || !form.ui?.status?.status) return; if (form.status === status) return; form.status = status; form.ui.status.status.hidden = false; form.ui.status.status.classList.toggle('loading', ['uploading', 'saving'].includes(status)); form.ui.status.message.textContent = message === '' ? this.getDefaultMessage(status) : message; 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; } getDefaultIcon(status) { const icons = { 'autosaved': 'check-circle', 'submitted': 'check-circle', 'restored': 'history', 'error': 'close-circle', 'offline': 'cloud-slash', 'pending': 'exclamation-mark' } return icons[status]??''; } /********************************************************************** SUMMARY **********************************************************************/ showSummary(data) { let summary = this.templates.create('formSummary', data); data.config.element.after(summary); window.fade(data.config.element, false); } /********************************************************************** UTILITY **********************************************************************/ 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); if (!config) return false; return config; } getField(element) { return element.closest('[data-field]'); } getFieldType(element) { let field = this.getField(element); if (!field) return; return field.dataset.fieldType; } getFieldValue(element) { let type = this.getFieldType(element); let conf = this.getItem(element); let fieldName = conf.field?.dataset.field??false; if (!fieldName) return false; switch (type) { case 'repeater': return this.getRepeaterValue(element, conf); case 'tag-list': return this.getTagListValue(element, conf); case 'group': //Do we actually need anything here? I think each subfield just break; case 'location': return this.getLocationValue(element, conf); case 'selector': case 'upload': case 'gallery': case 'image': return this.getHiddenInputValue(element, conf, fieldName); case 'true-false': case 'toggle-text': return element.checked; case 'checkbox': // Handle multi-checkbox (name ends with []) if (element.name.endsWith('[]')) { return this.getCheckboxGroupValue(element, conf); } return element.checked ? element.value : ''; default: return element.value; } } /** * Get all checked values for a checkbox group */ getCheckboxGroupValue(element, conf) { if (!conf.checkboxGroup) { conf.checkboxGroup = conf.field?.querySelectorAll(`input[type="checkbox"][name="${element.name}"]`); this.saveItem(conf); } return Array.from(conf.checkboxGroup) .filter(cb => cb.checked) .map(cb => cb.value); } /** * Get the actual user-facing value (for validation and submission) */ getFieldCheckedValue(element) { // Handle checkboxes and radios based on checked state if (element.type === 'checkbox') { const type = this.getFieldType(element); if (type === 'true-false') { return element.checked; } return element.checked ? element.value : ''; } if (element.type === 'radio') { const radioGroup = document.querySelectorAll(`input[name="${element.name}"]`); const checked = Array.from(radioGroup).find(r => r.checked); return checked ? checked.value : ''; } // For everything else, use existing logic return this.getFieldValue(element); } isEmptyValue(value) { if (value === null || value === undefined || value === '') return true; if (Array.isArray(value) && value.length === 0) return true; return typeof value === 'object' && Object.keys(value).length === 0; } 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); } } }); 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); } 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); }); return value; } 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 (!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 * @param {*} value - The field value * @param {Object} input - The input config * @returns {HTMLElement|string} - Formatted display element or string */ formatValueForSummary(value, input) { const fieldType = this.getFieldType(input.element); // Handle empty values if (this.isEmptyValue(value)) { return ''; } // Handle different field types switch (fieldType) { case 'repeater': return this.formatRepeaterForSummary(value, input); case 'tag-list': return this.formatTagListForSummary(value, input); case 'location': return this.formatLocationForSummary(value); case 'true-false': return value ? 'Yes' : 'No'; case 'checkbox': // Handle multi-checkbox arrays if (Array.isArray(value)) { return this.formatCheckboxGroupForSummary(value, input); } // Single checkbox - get display label return this.getDisplayLabel(input, value); 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); default: // For radio/checkbox, get the display label if (typeof value === 'string') { return this.getDisplayLabel(input, value); } // For textarea or any multi-line text, convert line breaks if (typeof value === 'string' && value.includes('\n')) { return this.convertLineBreaks(value); } return value; } } /** * Format checkbox group values with labels */ formatCheckboxGroupForSummary(values, input) { const labels = values.map(value => this.getDisplayLabel(input, value)); return labels.join(', '); } /** * Convert \n line breaks to HTML */ convertLineBreaks(text) { const container = document.createElement('span'); container.innerHTML = text.split('\n').join('
'); return container; } /** * Format repeater data as a list */ formatRepeaterForSummary(rows, input) { const container = document.createElement('div'); container.className = 'summary-repeater'; rows.forEach((row, index) => { const rowDiv = document.createElement('div'); rowDiv.className = 'summary-repeater-row'; const rowTitle = document.createElement('strong'); rowTitle.textContent = `Entry ${index + 1}:`; rowDiv.appendChild(rowTitle); const fieldsList = document.createElement('ul'); fieldsList.className = 'summary-repeater-fields'; for (const [fieldName, fieldValue] of Object.entries(row)) { if (this.isEmptyValue(fieldValue)) continue; const li = document.createElement('li'); // Try to find the label for this subfield const subFieldElement = input.field?.querySelector(`[data-field="${fieldName}"]`); const label = subFieldElement?.closest('.field')?.querySelector('label')?.textContent.replace('*', '').trim() || fieldName; li.innerHTML = `${label}: ${fieldValue}`; fieldsList.appendChild(li); } rowDiv.appendChild(fieldsList); container.appendChild(rowDiv); }); return container; } /** * Format tag-list data */ formatTagListForSummary(tags, input) { const container = document.createElement('div'); container.className = 'summary-taglist'; const tagsList = document.createElement('ul'); tagsList.className = 'summary-tags'; tags.forEach(tag => { const li = document.createElement('li'); li.className = 'summary-tag'; // Get the primary display value (first non-empty field) const displayValue = Object.values(tag).find(v => !this.isEmptyValue(v)) || ''; // If there are multiple fields, show them all const fields = Object.entries(tag).filter(([k, v]) => !this.isEmptyValue(v)); if (fields.length > 1) { li.textContent = fields.map(([k, v]) => v).join(', '); } else { li.textContent = displayValue; } tagsList.appendChild(li); }); container.appendChild(tagsList); return container; } /** * Format location data */ formatLocationForSummary(location) { const parts = []; if (location.street) parts.push(location.street); if (location.city) parts.push(location.city); if (location.province) parts.push(location.province); if (location.postal_code) parts.push(location.postal_code); if (location.country) parts.push(location.country); return parts.length > 0 ? parts.join(', ') : location.address || ''; } /** * Format hidden field types (upload, selector) */ formatHiddenFieldForSummary(value, input, fieldType) { if (['upload', 'gallery', 'image'].includes(fieldType)) { // Get upload preview images if available const uploadField = input.field?.querySelector('[data-upload-field]'); if (uploadField) { const previews = uploadField.querySelectorAll('.item-grid.preview img'); if (previews.length > 0) { const container = document.createElement('div'); container.className = 'summary-uploads'; previews.forEach(img => { const clone = img.cloneNode(true); clone.style.maxWidth = '100px'; clone.style.maxHeight = '100px'; container.appendChild(clone); }); return container; } } return `${value.split(',').length} file(s) uploaded`; } if (fieldType === 'selector') { // Could enhance this to show selected item names if available return value; } return value; } /** * Get the display label for an input value (especially for radio/checkbox) * @param {Object} input - The input config from this.inputs * @param {*} value - The field value * @returns {string} - The display label or original value */ getDisplayLabel(input, value) { if (!input.element) return value; const inputType = input.element.type; // Handle radio buttons if (inputType === 'radio') { const radioGroup = input.field.querySelectorAll(`input[type="radio"][name="${input.element.name}"]`); const selectedRadio = Array.from(radioGroup).find(radio => radio.value === value); if (selectedRadio) { const label = selectedRadio.closest('label') || input.field.querySelector(`label[for="${selectedRadio.id}"]`); if (label) { return label.textContent.replace('*', '').trim(); } } } // Handle checkboxes (including groups) if (inputType === 'checkbox' && this.getFieldType(input.element) !== 'true-false') { // Find checkbox with this value in the field const checkbox = input.field.querySelector(`input[type="checkbox"][value="${value}"]`); if (checkbox) { const label = checkbox.closest('label') || input.field.querySelector(`label[for="${checkbox.id}"]`); if (label) { // Get just the span content to avoid getting nested elements const span = label.querySelector('span'); return span ? span.textContent.trim() : label.textContent.replace('*', '').trim(); } } } return value; } getItem(element, formId = null) { const hasID = Object.hasOwn(element.dataset, 'ref'); let id = (hasID) ? element.dataset.ref : window.generateID('input'); if (!hasID) element.dataset.ref = id; //check if we have it already if (!this.inputs.has(id)) { if (!formId) { formId = element.closest('[data-form-id]')?.dataset.formId??false; } let field = this.getField(element); this.inputs.set(id, { id: id, element: element, form: formId, field: field, section: element.closest('[data-tab]')?.dataset.tab ?? false, ui: window.uiFromSelectors(this.selectors.fields, field) }); } return this.inputs.get(id); } saveItem(config) { this.inputs.set(config.id, config); } /********************************************************************** Subscription **********************************************************************/ subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(event, data) { this.subscribers.forEach(cb => { try { cb(event, data); } catch (e) { console.error('HandleSelection subscriber error:', e); } }); } /********************************************************************** Cleanup **********************************************************************/ destroy() { if (this.forms.size > 0) { Array.from(this.forms.values()).forEach(form => { this.removeFormListeners(form); }); this.forms.clear(); } if (this.repeaters.size > 0) { Array.from(this.repeaters.values()).forEach(repeater => { this.removeRepeaterListeners(repeater.element); repeater.sortable?.destroy(); }); this.repeaters.clear(); } if (this.quantityFields.size > 0) { Array.from(this.quantityFields.values()).forEach(num => { this.removeQuantityListeners(num.element); }); this.quantityFields.clear(); } if (this.tagLists.size > 0) { Array.from(this.tagLists.values()).forEach(tagList => { this.removeTagListListeners(tagList.element); }); this.tagLists.clear(); } if (this.charLimits.size > 0) { Array.from(this.charLimits.values()).forEach(charLimit => { charLimit.element.removeEventListener('input', this.countUpdaters); }); } this.inputs.clear(); this.forms.clear(); this.charLimits.clear(); } } document.addEventListener('DOMContentLoaded', async function () { window.auth.subscribe(event => { if (event === 'auth-loaded') { window.jvbForm = new FormController(); } }); });