Files
form-validation/validation.js
Dan Remollino 2cc6b61b33 the target of 'dependent' must now contain a value that passes
validation in order to remove the disabled status of the dependent field
2025-04-12 22:48:25 -04:00

515 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class ValidationField {
dependent = 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)
// dependent fields
if (this.dependent) {
const parents = this.validationInstance.form.querySelectorAll(`[name="${this.dependent}"]`)
const depCheck = () => {
let fieldsValid = true
const fields = this.validationInstance.getFields(parents[0].name)
for (const field of fields) {
for (const error of this.validationInstance.errors) {
if (field === error) fieldsValid = false
}
}
if (((parents[0].type === 'checkbox' || parents[0].type === 'radio') && this.validationInstance.form.querySelectorAll(`[name="${this.dependent}"]:checked`).length > 0 && fieldsValid) ||
(((parents[0].type !== 'checkbox' && parents[0].type !== 'radio') && parents[0].value && fieldsValid))) {
this.el.removeAttribute('disabled')
} else {
this.el.setAttribute('disabled', 'disabled')
}
}
depCheck()
for (const parent of parents) {
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',
`<span class="${this.validationInstance.reqIndicatorClass}">${this.validationInstance.reqIndicator}</span>`
)
}
}
// 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)
}
}
}
// get all fields with the same name attribute
getFields(nameAttr) {
let matches = []
for (const fieldInstance of this.reqFields) {
if (fieldInstance.el.name == nameAttr) {
matches.push(fieldInstance)
}
}
return matches
}
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',
`<div class="${this.messageClass}">${error.message}</div>`
)
}
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}))
}
}
}
}