class ValidationField { dependant = null el = null formGroupEl = null isValid = null labelEl = null labelText = null maxCount = null message = null messageOutputted = null minCount = 1 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) // dependant fields if (this.dependant) { const parent = this.validationInstance.form.querySelector(`[name=${this.dependant}]`) const depCheck = () => { parent.value ? this.el.removeAttribute('disabled') : this.el.setAttribute('disabled', 'disabled') } depCheck() parent.addEventListener('input', depCheck) } // add required indicator if (this.validationInstance.reqIndicators) { const hasIndicator = this.formGroupEl?.querySelector(`.${this.validationInstance.reqIndicatorClass}`) if (!hasIndicator && !this.optional && !this.el.disabled) { 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) { // combine all data-validation attr properties between // check/radio groups and copy to all inputs in the group if (el.type === 'checkbox' || el.type === 'radio') { const checks = this.form.querySelectorAll(`input[name="${el.name}"]`) let multiOptions = {} for (const check of checks) { Object.assign(multiOptions, check.dataset.validation ? JSON.parse(check.dataset.validation) : {}) el.dataset.validation = JSON.stringify(multiOptions) } } this.reqFields.push(new ValidationField({el: el, validationInstance: this})) } } // 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 }) } this.form.addEventListener('submit', e => { e.preventDefault() this.validate() }) this.form.addEventListener('reset', () => { this.removeErrors() }) // 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 && this.validateForBlank(fieldInstance) || (fieldInstance.optional && (fieldInstance.el.value !== '' && fieldInstance.el.checked))) { 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 } if (fieldInstance.pattern && fieldInstance.isValid) { this.validatePattern(fieldInstance) } } } validateForBlank(fieldInstance) { if (!fieldInstance.el.value) { this.addError( fieldInstance, (fieldInstance.labelText ? fieldInstance.labelText : `Field`) + ` is required.` ) return false } return true } validatePattern(fieldInstance) { const regex = new RegExp(fieldInstance.pattern) if (!regex.test(fieldInstance.el.value)) { this.addError(fieldInstance, fieldInstance.patternMessage) } } validateCheckbox(fieldInstance, nameAttrVal) { const checkCount = this.form.querySelectorAll(`[name="${nameAttrVal}"]:checked`).length let message if (fieldInstance.maxCount && (checkCount < fieldInstance.minCount || checkCount > fieldInstance.maxCount)) { message = fieldInstance.minCount !== fieldInstance.maxCount ? `At least ${fieldInstance.minCount}, and no more than ${fieldInstance.maxCount} choices are required.` : `${fieldInstance.minCount} choices are required.` this.addError(fieldInstance, message) } else if (checkCount < fieldInstance.minCount) { let messageMod = fieldInstance.minCount === 1 ? `choice is` : `choices are` message = `At least ${fieldInstance.minCount} ${messageMod} required.` this.addError(fieldInstance, message) } } 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.message}
` ) } 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) error.isValid = true error.messageOutputted = false } 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})) } } } }