| | |
| | | field: '.field', //querySelectorAll |
| | | label: 'label', |
| | | success: '.success', |
| | | error: '.success', |
| | | error: '.error', |
| | | message: '.validation-message', |
| | | }, |
| | | repeater: { |
| | |
| | | }, |
| | | tagList: { |
| | | tagList: '.field.tag-list', //querySelectorAll |
| | | input: '.tag-input-row', |
| | | 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 |
| | | }, |
| | |
| | | input: 'input[type="number"]' |
| | | }, |
| | | limits: { |
| | | hasLimit: '[data-limit]', |
| | | hasLimit: '[data-maxlength]', |
| | | limit: '.limit', |
| | | current: '.current', |
| | | } |
| | |
| | | if (e.target.closest('[data-ignore]') || this.isRestoring) return; |
| | | |
| | | let field = this.getField(e.target); |
| | | |
| | | //Dependencies |
| | | if (this.dependencies.has(field.dataset.field)) { |
| | | let dependency = this.dependencies.get(field.dataset.field); |
| | |
| | | }); |
| | | } |
| | | |
| | | 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); |
| | | } |
| | |
| | | |
| | | if (this.subscribers.size > 0) { |
| | | e.preventDefault(); |
| | | console.log('Cancelling scheduled backup and manually backing up'); |
| | | this.cancelBackup(); |
| | | await this.backup(); |
| | | const storedData = await this.store.get(form.id); |
| | | |
| | | 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 |
| | |
| | | this.removeQuantityListeners(item.element); |
| | | break; |
| | | } |
| | | |
| | | if (check.has(item.id)) { |
| | | check.delete(item.id); |
| | | } |
| | | }); |
| | | check.delete(item.id); |
| | | } |
| | | } |
| | | |
| | |
| | | let conf = this.quantityFields.get(e.target.closest('[data-num-id]')?.dataset.numId); |
| | | if(!conf) return; |
| | | let change = 0; |
| | | if (conf.increase.contains(e.target)) { |
| | | if (conf.ui.increase.contains(e.target)) { |
| | | change++; |
| | | } else if (conf.decrease.contains(e.target)) { |
| | | } else if (conf.ui.decrease.contains(e.target)) { |
| | | change--; |
| | | } |
| | | if (change === 0) return; |
| | | let field = this.getField(e.target); |
| | | let step = conf.input.step; |
| | | let step = conf.ui.input.step; |
| | | step = Math.max(step, 1); |
| | | if (e.ctrlKey && e.shiftKey) { |
| | | step = step * 50; |
| | |
| | | } else if (e.shiftKey) { |
| | | step = step * 10; |
| | | } |
| | | let value = (conf.input.value === '') ? 0 : parseFloat(conf.input.value); |
| | | conf.input.value = (value + (step * change)); |
| | | let value = (conf.ui.input.value === '') ? 0 : parseFloat(conf.ui.input.value); |
| | | conf.ui.input.value = (value + (step * change)); |
| | | |
| | | value = parseFloat(conf.input.value); |
| | | value = parseFloat(conf.ui.input.value); |
| | | |
| | | if (conf.input.min && value < conf.input.min) { |
| | | conf.input.value = conf.input.min; |
| | | conf.decrease.disabled = true; |
| | | } else if (conf.input.max && value > conf.input.max) { |
| | | conf.input.value = conf.input.max; |
| | | conf.increase.disabled = true; |
| | | if (conf.ui.input.min && value < conf.ui.input.min) { |
| | | conf.ui.input.value = conf.ui.input.min; |
| | | conf.ui.decrease.disabled = true; |
| | | } else if (conf.ui.input.max && value > conf.ui.input.max) { |
| | | conf.ui.input.value = conf.ui.input.max; |
| | | conf.ui.increase.disabled = true; |
| | | } else { |
| | | if (conf.decrease.disabled) conf.decrease.disabled = false; |
| | | if (conf.increase.disabled) conf.increase.disabled = false; |
| | | if (conf.ui.decrease.disabled) conf.ui.decrease.disabled = false; |
| | | if (conf.ui.increase.disabled) conf.ui.increase.disabled = false; |
| | | } |
| | | } |
| | | checkForRepeaters(form) { |
| | | |
| | | if (!form.querySelector(this.selectors.repeater.repeater)) return; |
| | | |
| | | form.querySelectorAll(this.selectors.repeater.repeater).forEach(repeater => { |
| | |
| | | sortable: false, |
| | | }; |
| | | |
| | | if (!config.ui.addButton) return; |
| | | if (!config.ui.add) return; |
| | | |
| | | let template = repeater.querySelector('template'); |
| | | this.templates.define( |
| | |
| | | setup({el, refs, manyRefs, data}) { |
| | | let index = config.ui.items?.children?.length??0; |
| | | el.dataset.index = index; |
| | | |
| | | |
| | | manyRefs.inputs?.forEach(input => { |
| | | window.prefixInput(input, `${el.dataset.fieldName}:${index}:`) |
| | | window.prefixInput(input, `${data.repeater.dataset.field}:${index}:`, el); |
| | | }); |
| | | } |
| | | }, |
| | |
| | | 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); |
| | | this.removeRepeaterRow(e.target.closest('[data-index]')); |
| | | } |
| | | } |
| | | addRepeaterRow(repeater) { |
| | | repeater.append(this.templates.create(repeater.dataset.repeaterId)); |
| | | 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) { |
| | |
| | | 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( |
| | |
| | | let index = config.ui.items?.children?.length??0; |
| | | el.dataset.index = index; |
| | | manyRefs.inputs?.forEach(input => { |
| | | window.prefixInput(input, `${el.dataset.fieldName}:${index}:`) |
| | | let wrapper = input.closest('.tag-item'); |
| | | window.prefixInput(input, `${data.fieldName}:${index}:`, wrapper) |
| | | }); |
| | | |
| | | if (refs.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, {passive: true}) |
| | | el.addEventListener('keypress', this.tagListInput); |
| | | } |
| | | removeTagListListeners(el) { |
| | | el.removeEventListener('click', this.tagListClick); |
| | | el.removeEventListener('keypress', this.tagListInput) |
| | | el.removeEventListener('keypress', this.tagListInput); |
| | | } |
| | | |
| | | handleTagListClick(e) { |
| | | if (e.target.matches(this.selectors.tagList.add)) { |
| | | if (window.targetCheck(e,this.selectors.tagList.add)) { |
| | | this.addTagListItem(e.target.closest('[data-tag-list-id]')); |
| | | } else if (e.target.matches(this.selectors.tagList.remove)) { |
| | | this.removeTagListItem(e.target.closest(this.selectors.tagList.remove)); |
| | | } else if (window.targetCheck(e, this.selectors.tagList.remove)) { |
| | | this.removeTagListItem(e.target.closest(this.selectors.tagList.item)); |
| | | } |
| | | } |
| | | addTagListItem(tagList) { |
| | | let config = this.tagLists.get(tagList.dataset.tagListId); |
| | | if (!config) return; |
| | | addTagListItem(tagList) { |
| | | let config = this.tagLists.get(tagList.dataset.tagListId); |
| | | if (!config) return; |
| | | |
| | | let data = {}; |
| | | let hasValue = false; |
| | | let data = {}; |
| | | let hasValue = false; |
| | | let isValid = true; |
| | | |
| | | for (let input of config.ui.inputs) { |
| | | this.validateField(input); |
| | | const fieldName = input.name.replace('new_',''); |
| | | const value = this.getFieldValue(input); |
| | | if (value) hasValue = true; |
| | | data[fieldName] = value; |
| | | // First pass: validate all inputs |
| | | for (let input of config.ui.inputs) { |
| | | const isRequired = input.required || input.dataset.required === 'true'; |
| | | const value = this.getFieldValue(input); |
| | | |
| | | //clear values and validation |
| | | if (['checkbox', 'radio'].includes(input.type)) { |
| | | input.checked = false; |
| | | } else { |
| | | input.value = ''; |
| | | if (value) hasValue = true; |
| | | |
| | | // Validate and check for errors |
| | | const valid = this.validateField(input); |
| | | |
| | | if (isRequired && !value) { |
| | | this.showError(input, 'This field is required'); |
| | | isValid = false; |
| | | } else if (!valid) { |
| | | isValid = false; |
| | | } |
| | | |
| | | const fieldName = input.name.replace('new_',''); |
| | | data[fieldName] = value; |
| | | } |
| | | |
| | | // Stop if validation failed |
| | | if (!isValid) { |
| | | this.a11y.announce('Please correct the errors before adding'); |
| | | const firstInvalid = config.ui.inputs.find(input => { |
| | | const isRequired = input.required || input.dataset.required === 'true'; |
| | | return (isRequired && !this.getFieldValue(input)); |
| | | }); |
| | | if (firstInvalid) firstInvalid.focus(); |
| | | return; |
| | | } |
| | | |
| | | if (!hasValue) { |
| | | this.a11y.announce('Please fill in at least one field'); |
| | | config.ui.inputs[0].focus(); |
| | | return; |
| | | } |
| | | |
| | | // Build label |
| | | let label; |
| | | switch (config.format) { |
| | | case 'first_field': |
| | | label = Object.values(data)[0]; |
| | | break; |
| | | case 'all_fields': |
| | | label = Object.values(data).join(', '); |
| | | break; |
| | | default: |
| | | if (config.format.includes('{')) { |
| | | label = config.format; |
| | | for (const [key, value] of Object.entries(data)) { |
| | | label = label.replace(`{${key}}`, value); |
| | | } |
| | | this.clearValidation(input); |
| | | } else { |
| | | label = data[config.format]??Object.values(data)[0]; |
| | | } |
| | | break; |
| | | } |
| | | |
| | | if (!hasValue) { |
| | | this.a11y.announce('Please fill in at least one field'); |
| | | config.ui.inputs[0].focus(); |
| | | return; |
| | | } |
| | | let newItem = this.templates.create(tagList.dataset.tagListId, { |
| | | label: label, |
| | | fieldName: config.fieldName |
| | | }); |
| | | |
| | | let label; |
| | | switch (config.format) { |
| | | case 'first_field': |
| | | label = Object.values(data)[0]; |
| | | break; |
| | | case 'all_fields': |
| | | label = Object.values(data).join(', '); |
| | | break; |
| | | default: |
| | | if (format.includes('{')) { |
| | | let label = config.format; |
| | | for (const [key, value] of Object.entries(data)) { |
| | | label = label.replace(`{${key}}`, value); |
| | | } |
| | | } else { |
| | | label = data[config.format]??Object.values(data)[0]; |
| | | } |
| | | break; |
| | | } |
| | | const index = config.ui.items?.children?.length ?? 0; |
| | | newItem?.querySelectorAll('input[type=hidden]')?.forEach(input => { |
| | | const fieldKey = input.dataset.field; |
| | | input.name = `${config.fieldName}:${index}:${fieldKey}`; |
| | | input.id = `${config.fieldName}:${index}:${fieldKey}`; |
| | | input.value = data[fieldKey] || ''; |
| | | }); |
| | | |
| | | let newItem = this.templates.create(tagList.dataset.tagListId, { |
| | | label: label |
| | | }); |
| | | config.ui.items.append(newItem); |
| | | |
| | | const index = config.ui.items?.children?.length ?? 0; |
| | | newItem?.querySelectorAll('input[type=hidden]')?.forEach(input => { |
| | | const fieldKey = input.dataset.field; |
| | | input.name = `${config.element.field}:${index}:${fieldKey}`; |
| | | input.value = data[fieldKey] || ''; |
| | | }); |
| | | |
| | | config.ui.items.append(newItem); |
| | | config.ui.inputs[0]?.focus(); |
| | | |
| | | this.a11y.announce('Item added'); |
| | | // Clear inputs AFTER success |
| | | for (let input of config.ui.inputs) { |
| | | if (['checkbox', 'radio'].includes(input.type)) { |
| | | input.checked = false; |
| | | } else { |
| | | input.value = ''; |
| | | } |
| | | removeTagListItem(tag) { |
| | | let tagList = tag.closest('[data-tag-list-id]'); |
| | | tag.remove(); |
| | | this.reindexList(tagList); |
| | | this.a11y.announce('Item removed'); |
| | | } |
| | | this.clearValidation(input); |
| | | } |
| | | |
| | | config.ui.inputs[0]?.focus(); |
| | | this.updateCollectionField(tagList); |
| | | this.a11y.announce('Item added'); |
| | | } |
| | | removeTagListItem(item) { |
| | | let tagList = item.closest('[data-tag-list-id]'); |
| | | if (!tagList) return; |
| | | item.remove(); |
| | | this.reindexList(tagList); |
| | | this.updateCollectionField(tagList); |
| | | this.a11y.announce('Item removed'); |
| | | } |
| | | handleTagListInput(e) { |
| | | let target = e.target; |
| | | let field = target.closest('[data-tag-list-id]'); |
| | |
| | | } |
| | | }); |
| | | } |
| | | checkForCharacterLimits(form) { |
| | | if (!form.querySelector(this.selectors.limits.hasLimit)) return; |
| | | this.countUpdaters = this.updateCount.bind(this); |
| | | checkForCharacterLimits(form) { |
| | | if (!form.querySelector(this.selectors.limits.hasLimit)) return; |
| | | this.countUpdaters = this.updateCount.bind(this); |
| | | |
| | | form.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach(input => { |
| | | let id = window.generateID('limit'); |
| | | input.dataset.charLimitId = id; |
| | | let config = { |
| | | element: input, |
| | | form: form.dataset.formId, |
| | | ui: window.uiFromSelectors(this.selectors.limits, input.closest('.field')) |
| | | }; |
| | | config.ui.limit.textContent = input.dataset.limit; |
| | | this.charLimits.set(id, config); |
| | | form.querySelectorAll(this.selectors.limits.hasLimit).forEach(field => { |
| | | const input = field.querySelector('input, textarea, select'); |
| | | if (!input) return; |
| | | |
| | | this.addCharacterLimitListeners(input); |
| | | }); |
| | | let id = window.generateID('limit'); |
| | | input.dataset.charLimitId = id; |
| | | input.dataset.limit = field.dataset.maxlength; |
| | | |
| | | let config = { |
| | | element: input, |
| | | form: form.dataset.formId, |
| | | ui: window.uiFromSelectors(this.selectors.limits, field) |
| | | }; |
| | | |
| | | if (config.ui.limit) { |
| | | config.ui.limit.textContent = field.dataset.maxlength; |
| | | } |
| | | |
| | | this.charLimits.set(id, config); |
| | | this.addCharacterLimitListeners(input); |
| | | }); |
| | | } |
| | | addCharacterLimitListeners(input) { |
| | | input.addEventListener('input', this.countUpdaters, {passive: true}); |
| | | } |
| | |
| | | * @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}`; |
| | | Array.from(item.children).forEach(child => { |
| | | if (child.type === 'hidden') { |
| | | window.prefixInput( |
| | | child, |
| | | `${container.dataset.field}:${index}:${child.dataset.field}` |
| | | ); |
| | | } |
| | | |
| | | // 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 // Pass the item as wrapper for label lookup |
| | | ); |
| | | }); |
| | | }); |
| | | |
| | | //schedule save |
| | | |
| | | 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.querySelector('input, select, textarea')); |
| | | this.updateItem(field.dataset.field, value, form); |
| | | } |
| | | /********************************************************************** |
| | | VALIDATION |
| | |
| | | if (data.field) { |
| | | const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`); |
| | | if (fieldWrapper) { |
| | | // Use existing showError method for consistency |
| | | this.showError(fieldWrapper, data.message); |
| | | |
| | | // Mark as touched so validation persists |
| | | this.touchedFields.add(data.field); |
| | | |
| | | // Scroll to error |
| | | fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| | | |
| | | // Focus the input for better UX |
| | | const input = fieldWrapper.querySelector('input, textarea, select'); |
| | | if (input) { |
| | | input.focus(); |
| | | } |
| | | } |
| | | } else { |
| | | // General form error (not field-specific) |
| | | const error = document.createElement('div'); |
| | | error.className = 'form-error error-message'; |
| | | error.textContent = data.message; |
| | | |
| | | // Add icon for consistency |
| | | const icon = window.getIcon?.('close-circle'); |
| | | if (icon) { |
| | | icon.classList.add('error-icon'); |
| | |
| | | } |
| | | |
| | | form.insertBefore(error, form.firstChild); |
| | | |
| | | // Scroll to top to show the error |
| | | form.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| | | } |
| | | |
| | | // Announce error for accessibility |
| | | if (window.jvbA11y) { |
| | | const announcement = data.field |
| | | ? `Error in ${data.field}: ${data.message}` |
| | |
| | | window.jvbA11y.announce(announcement); |
| | | } |
| | | |
| | | // Trigger custom event |
| | | form.dispatchEvent(new CustomEvent('jvb-form-error', { |
| | | detail: data |
| | | })); |
| | |
| | | **********************************************************************/ |
| | | 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); |
| | |
| | | }); |
| | | this.tagLists.clear(); |
| | | } |
| | | if(this.charLimits.size > 0) { |
| | | if (this.charLimits.size > 0) { |
| | | Array.from(this.charLimits.values()).forEach(charLimit => { |
| | | charLimit.removeEventListener('input', this.countUpdaters); |
| | | }) |
| | | charLimit.element.removeEventListener('input', this.countUpdaters); |
| | | }); |
| | | } |
| | | this.inputs.clear(); |
| | | this.forms.clear(); |