515 lines
21 KiB
JavaScript
515 lines
21 KiB
JavaScript
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}))
|
||
}
|
||
}
|
||
}
|
||
}
|