class ValidationField { el = null formGroupEl = null isValid = null labelEl = null labelText = null message = null messageOutputted = null optional = false pattern = null patternMessage = null validationInstance = null valueSrc = [] // [css_selector, 'attribute|attributeValue'|'exists'|'textContent', attribute_name|element] constructor(obj) { try { Object.assign(this, obj) this.formGroupEl = this.el.closest('.form-group') this.labelEl = this.formGroupEl?.querySelector('legend') ?? this.validationInstance.form.querySelector( `[name="${this.el.getAttribute('name')}"]:not([type="hidden"])` ).closest('.form-group').querySelector('label') this.labelText = this.labelEl?.textContent.replace(this.validationInstance.reqIndicator, '') // user field config try { Object.assign(this, this.el.dataset.validation ? JSON.parse(this.el.dataset.validation) : {}) } catch(error) { if (error instanceof SyntaxError) { console.warn(`There was a problem configuring your field options. Using defaults. Are you using a properly formatted JSON object? (e.g. data-validation='{"optional": true}')\n\nElement: ${this.el.outerHTML}`) } delete this.el.dataset.validation } // custom form controls if (this.valueSrc.length > 0) { const valueProps = { valueRef: this.validationInstance.form.querySelector(this.valueSrc[0]), kind: this.valueSrc[1], attr: this.valueSrc[2] } let observerOpts const updateValue = (valueProps) => { switch (valueProps.kind) { case 'attribute': valueProps.valueRef.hasAttribute(valueProps.attr) ? this.el.setAttribute('value', '1') : this.el.setAttribute('value', '') observerOpts = {attributeFilter: [valueProps.attr]} break case 'attributeValue': valueProps.valueRef.getAttribute(valueProps.attr) ? this.el.setAttribute('value', valueProps.valueRef.getAttribute(valueProps.attr)) : this.el.setAttribute('value', '') observerOpts = {attributeFilter: [valueProps.attr]} break case 'exists': valueProps.valueRef.querySelector(valueProps.attr) ? this.el.setAttribute('value', '1') : this.el.setAttribute('value', '') observerOpts = {childList: true} break case 'textContent': this.el.setAttribute('value', valueProps.valueRef?.textContent.trim()) observerOpts = {characterData: true, childList: true, subtree: true} break } } updateValue(valueProps) const custConObserver = new MutationObserver((mutationList, observer) => { updateValue(valueProps) this.validationInstance.validate(this) }) custConObserver.observe(valueProps.valueRef, observerOpts) // focus on the value target when the label is clicked this.labelEl.addEventListener('click', () => { valueProps.valueRef.focus() }) } // validate individual fields according to the parent Validation // instance `valFieldsOn` value this.el.addEventListener(this.validationInstance.valFieldsOn, () => { this.validationInstance.validate(this) }) // add a native pattern attr if pattern property is present if (this.pattern) this.el.setAttribute('pattern', this.pattern) // add required indicator if (this.validationInstance.reqIndicators) { const hasIndicator = this.formGroupEl?.querySelector(`.${this.validationInstance.reqIndicatorClass}`) if (!hasIndicator && !this.optional) { this.labelEl?.insertAdjacentHTML( 'beforeend', `${this.validationInstance.reqIndicator}` ) } } // validate the field without outputting errors to set the default state this.validationInstance.validate(this, false, true) // console.log(this) } catch (error) { console.warn(`Something when wrong setting up a ValidationField instance.`) } } } class Validation { static forms = [] errors = [] form = null invalidClass = 'invalid' messageClass = 'invalid__message' onlyOnSubmit = false onSuccess = null passConfirm = true passConfirmField = null passwordField = null reqAll = true reqIndicator = '*' reqIndicators = true reqIndicatorClass = 'required__indicator' reqData = new Set() reqFields = [] valFieldsOn = 'input' // blur, change, input constructor(obj) { Object.assign(this, obj) // check for custom form controls const custFormControls = this.form.querySelectorAll('[data-validation]:not(form, input, select, textarea)') if (custFormControls.length > 0) { for (const el of custFormControls) { try { const fieldOptions = JSON.parse(el.dataset.validation) if (!this.form.querySelector(fieldOptions.valueSrc[0])) { throw new Error(`No element matched with the supplied selector – ${fieldOptions.valueSrc[0]}`) } let inputEl = document.createElement('input') inputEl.dataset.validation = el.dataset.validation let inputAttrs = {name: el.getAttribute('name'), type: 'hidden', value: ''} for (const [key, value] of Object.entries(inputAttrs)) { inputEl[key] = value } el.parentElement.prepend(inputEl) } catch (error) { console.warn(`There was a problem configuring your custom form control. Aborted.\n\nError: ${error.message}.\n\nElement: ${el.outerHTML}`) } } } // add confirm password field if a password field is present if (this.passConfirm) { const pwField = this.form.querySelector('input[type="password"]') if (pwField) { this.passwordField = new ValidationField({el: pwField, validationInstance: this}) const passConfirmGroup = this.passwordField.formGroupEl.cloneNode(true) for (const child of passConfirmGroup.children) { switch (child.tagName) { case 'LABEL': child.innerText = 'Password Confirm' child.htmlFor = 'password-confirm' break case 'INPUT': child.setAttribute('id', 'password-confirm') child.setAttribute('name', 'password-confirm') break } } this.passwordField.formGroupEl.insertAdjacentHTML('afterend', passConfirmGroup.outerHTML) this.passConfirmField = new ValidationField({el: this.form.querySelector('input[name="password-confirm"]'), validationInstance: this}) } } // add required attr to fields if reqAll == true if (this.reqAll) { for (const field of this.form.querySelectorAll('input, select, textarea')) { field.setAttribute('required', 'required') } } // check for fields that are elidgeable for validation and make a set of unique name attr values for (const field of this.form.querySelectorAll(':where(input, select, textarea)[required][name]:not([disabled])')) { this.reqData.add(field.name) } // create a list of all the field elements that were specified for validation for (const nameAttr of this.reqData) { const fields = this.form.querySelectorAll(`[name="${nameAttr}"]:where(input, select, textarea)`) for (const el of fields) { this.reqFields.push(new ValidationField({el: el, validationInstance: this})) } } this.form.addEventListener('submit', e => { e.preventDefault() this.validate() }) this.form.addEventListener('reset', () => { this.removeErrors() }) // add/remove a field when disabled attr is dynamically toggled const observeEls = this.form.querySelectorAll('input, select, textarea, [data-validation]:not(form, input, select, textarea)') const disabledObserver = new MutationObserver((mutationList, observer) => { for (const item of mutationList) { let updateEl = item.target if (item.target.dataset.validation?.includes('valueSrc')) { // custom form control – update the hidden input instead updateEl = this.form.querySelector( `input[name="${item.target.getAttribute('name')}"][type="hidden"]` ) } updateEl.hasAttribute('disabled') ? this.removeField(updateEl) : this.addField(updateEl) } }) for (const el of observeEls) { disabledObserver.observe(el, { attributes: true, attributeFilter: ['disabled'], attributeOldValue: true }) } // console.log(this) } // add a field after initialization addField(el) { this.reqData.add(el.name) this.reqFields.push(new ValidationField({el: el, validationInstance: this})) } // remove a field after initialization removeField(el) { this.reqData.delete(el.name) for (const obj of this.reqFields) { if (el === obj.el) { obj.formGroupEl.querySelector(`.${this.reqIndicatorClass}`)?.remove() this.reqFields.pop(obj) this.removeErrors(obj) } } } parseField(fieldInstance) { const nameAttrVal = fieldInstance.el.name if (!fieldInstance.optional) { if (!fieldInstance.el.value) { this.validateForBlank(fieldInstance) } else { if (fieldInstance.pattern) { this.validateForPattern(fieldInstance) } else { switch (fieldInstance.el.type) { case 'checkbox': this.validateCheckbox(fieldInstance, nameAttrVal) break case 'email': this.validateEmail(fieldInstance) break case 'password': if (fieldInstance.el.name === 'password') this.validatePassword(fieldInstance) if (fieldInstance.el.name === 'password-confirm') this.validateConfirmPass(fieldInstance) break case 'radio': this.validateRadio(fieldInstance, nameAttrVal) break case 'tel': this.validateTel(fieldInstance) break } } } } } validateForBlank(fieldInstance) { this.addError( fieldInstance, (fieldInstance.labelText ? fieldInstance.labelText : `Field`) + ` is required.` ) } validateForPattern(fieldInstance) { const regex = new RegExp(fieldInstance.pattern) if (!regex.test(fieldInstance.el.value)) { this.addError(fieldInstance, fieldInstance.patternMessage) } } validateCheckbox(fieldInstance, nameAttrVal) { if (!this.form.querySelector(`[name="${nameAttrVal}"]:checked`)) { this.addError(fieldInstance, `At least one choice is required.`) } } validateEmail(fieldInstance) { const regex = new RegExp(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/) if (!regex.test(fieldInstance.el.value)) { this.addError(fieldInstance, `'${fieldInstance.el.value}' is not a valid email address.`) } } validatePassword(fieldInstance) { if (this.passConfirmField.el.value) { if (this.passConfirmField.el.value !== this.passwordField.el.value) { this.validate(this.passConfirmField) } else { this.removeErrors(this.passConfirmField) } } // minimum eight characters, at least one upper case English letter, // one lower case English letter, one number and one special character const regex = new RegExp(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$ %^&*-]).{8,}$/) if (!regex.test(fieldInstance.el.value)) { this.addError(fieldInstance, `Password is not secure.`) } } validateConfirmPass(fieldInstance) { if (fieldInstance.el.value !== this.passwordField.el.value) { this.removeErrors(fieldInstance) this.addError(fieldInstance, `Does not match Password field.`) } } validateRadio(fieldInstance, nameAttrVal) { if (!this.form.querySelector(`[name="${nameAttrVal}"]:checked`)) { this.addError(fieldInstance, `A choice is required.`) } } validateTel(fieldInstance) { // 1234567890, 123-456-7890, (123)456-7890, (123) 456-7890 const regex = new RegExp(/^\(?(\d{3})\)?[- ]?(\d{3})[- ]?(\d{4})$/) if (!regex.test(fieldInstance.el.value)) { this.addError(fieldInstance, `'${fieldInstance.el.value}' is not a valid telephone number.`) } } addError(fieldInstance, message) { fieldInstance.isValid = false fieldInstance.message = message this.errors.push(fieldInstance) } outputErrors(fieldInstance) { for (const error of this.errors) { if (!fieldInstance || error.el === fieldInstance.el) { error.formGroupEl.classList.add(`${this.invalidClass}`) if (error.message) { error.el.insertAdjacentHTML( 'beforeBegin', `
` ) } error.messageOutputted = true } } } removeErrors(fieldInstance) { const updateMarkup = valField => { valField.formGroupEl.classList.remove(`${this.invalidClass}`) valField.formGroupEl.querySelector(`.${this.messageClass}`)?.remove() } if (fieldInstance) { for (const [index, error] of this.errors.entries()) { if (error.el.name === fieldInstance.el.name) { this.errors.splice(index, 1) } } updateMarkup(fieldInstance) fieldInstance.isValid = true fieldInstance.messageOutputted = false } else { for (const error of this.errors) { updateMarkup(error) } this.errors.length = 0 } } validate(fieldInstance, outputErrors = true, force = false) { if (fieldInstance) { if (!this.onlyOnSubmit || (this.onlyOnSubmit && fieldInstance.messageOutputted) || force) { this.removeErrors(fieldInstance) this.parseField(fieldInstance) if (outputErrors) this.outputErrors(fieldInstance) } } else { if (this.errors.length > 0) this.removeErrors() for (const nameAttrVal of this.reqData) { for (const field of this.reqFields) { if (field.el.name === nameAttrVal) { this.parseField(field) break } } } this.outputErrors() } // console.log(this); return; // form was sbmitted and all fields pass validation if (!fieldInstance && this.errors.length === 0) { this.onSuccess ? Function(this.onSuccess)() : this.form.submit() } } static { const forms = document.getElementsByTagName('form') for (let form of forms) { form.noValidate = true try { const config = Object.assign( {form: form}, form.dataset.validation ? JSON.parse(form.dataset.validation) : {} ) Validation.forms.push(new Validation(config)) } catch(error) { if (error instanceof SyntaxError) console.warn(`There was a problem configuring your custom validation options. Using defaults. Are you using a properly formatted JSON object? (e.g. data-validation='{"reqIndicators": false}')`) delete form.dataset.validation Validation.forms.push(new Validation({form: form})) } } } }