added demo site

This commit is contained in:
2025-04-03 17:45:27 -04:00
parent 3a0ad9d804
commit 50af7ae33a
15 changed files with 1732 additions and 1 deletions

View 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}))
}
}
}
}