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.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.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 (element.tagName !== 'INPUT' || element.type !== 'hidden'){
element = element.querySelector('input[type="hidden"][name="'+fieldName+'"]');
if (!element) {
return;
}
}
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
* @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();
}
});
});