added demo site
This commit is contained in:
450
demo/js/validation/validation.js
Normal file
450
demo/js/validation/validation.js
Normal file
@ -0,0 +1,450 @@
|
||||
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',
|
||||
`<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) {
|
||||
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.validatePattern(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.`
|
||||
)
|
||||
}
|
||||
|
||||
validatePattern(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',
|
||||
`<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)
|
||||
}
|
||||
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}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user