import type { Action } from 'svelte/action'; /** InputElement is a union of all input types that can be validated. */ export type InputElement = | HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLDivElement; /** InputNodeList is a union of all node lists that can be validated. */ // export type InputNodeList = NodeListOf | NodeListOf | []; export type InputNodeList = NodeListOf; // getNodeValue is a utility function that returns the value of an input element. export const getNodeValue = (node: InputElement): string => { if (node instanceof HTMLDivElement) { return node.textContent || ''; } else { return node.value; } }; // setNodeValue is a utility function that sets the value of an input element. export const setNodeValue = (node: InputElement, value: string): void => { if (node instanceof HTMLDivElement) { node.textContent = value; } else { node.value = value; if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { // Update selection range to the end of the input value node.setSelectionRange(value.length, value.length); } } }; /** ValidatorOptions configures the behavior of a validator. */ export type ValidatorOptions = { /** required is a flag that indicates whether the input is required. */ required?: boolean; /** pattern is a regex pattern that the input value must match. */ pattern?: RegExp; /** minlength is the minimum length of the input value. */ minlength?: number; /** maxlength is the maximum length of the input value. */ maxlength?: number; /** length is the exact length of the input value. */ length?: number; /** NOT IMPLEMENTED. type is a predefined validation type. */ type?: 'email' | 'phone' | 'score'; /** baseval is a value that the input value must not match (an alternate zero-value). */ baseval?: string; /** autovalOnInvalid is a flag that automatically adds a keyup listener to the input on invalid. */ autovalOnInvalid?: boolean; /** func is checked after other validation rules */ func?: (node: InputElement) => boolean | Promise; /** valfunc is called early in validation */ valfunc?: (val: string) => boolean | Promise; }; /** InputValidatorPayload carries the current value of an input element when it is validated. */ export type InputValidatorPayload = { value: string; valid: boolean }; /** InputValidatorEvent is a custom event that is dispatched when an input is validated. */ export class InputValidatorEvent extends CustomEvent {} /** FormValidatorPayload carries a list of invalid and valid inputs when a form is validated. */ export type FormValidatorPayload = { invalidInputs: InputElement[]; validInputs: InputElement[]; valid: boolean; }; /** FormValidatorEvent is a custom event that is dispatched when a form is validated. */ export class FormValidatorEvent extends CustomEvent {} /** ValidatorState is the state of a validator. */ export type ValidatorState = 'unknown' | 'valid' | 'invalid'; const VALIDATOR_VALID: ValidatorState = 'valid'; const VALIDATOR_INVALID: ValidatorState = 'invalid'; const VALIDATOR_UNKNOWN: ValidatorState = 'unknown'; /** QUERY_INPUTS is the query string for all input elements that can be validated. */ const QUERY_INPUTS = 'input[data-validate-identifier], textarea[data-validate-identifier]'; /** DATA_ID is the key for the validator index in the input element's dataset. */ const DATA_ID = 'validateIdentifier'; /** DATA_STATE is the key for the validation state in the input element's dataset. */ const DATA_STATE = 'validateState'; /** email is a regex pattern for validating email addresses. */ export const email: RegExp = new RegExp(String.raw`^[^@\s]+@[^@\s]+\.[^@\s]+$`); /** phone is a regex pattern for validating phone numbers. */ export const phone: RegExp = new RegExp(String.raw`^(\+)?(\(?\d+\)?)(([\s-]+)?(\d+)){0,}$`); /** score is a regex pattern for validating lesson grades. */ export const score: RegExp = new RegExp(String.raw`^(-?\d{0,2}|100)$`); /** * InputState is some historical state of an input element, tracking its value, * selectionStart and selectionEnd. */ export type InputState = { value: string; selectionStart?: number; selectionEnd?: number; }; let nextValidatorID = 0; const validators: Record = {}; const lastState: Record = {}; /** * validate attaches rules to an input element that can be used to validate the input. * If opts is undefined, no validation rules will be attached. */ export const validate: Action< InputElement, ValidatorOptions | undefined, { onvalidate: (e: InputValidatorEvent) => void; } > = (node, buildOpts) => { if (!buildOpts) return; const validatorID = nextValidatorID++; const propagateOpts = (opts: typeof buildOpts) => { validators[validatorID] = opts; }; node.dataset[DATA_ID] = String(validatorID); propagateOpts(buildOpts); node.dataset[DATA_STATE] = VALIDATOR_UNKNOWN; // set default validator state return { update: (updateOpts) => { if (updateOpts) propagateOpts(updateOpts); // if current state is invalid, revalidate if (node.dataset[DATA_STATE] === VALIDATOR_INVALID) { validateInput(node); } }, destroy: () => { delete node.dataset[DATA_ID]; delete node.dataset[DATA_STATE]; delete validators[validatorID]; } }; }; /** * keydownValidator attaches a keydown event listener to an input element that will * process the input value according to any attached validation rules, triggering * valid or invalid depending on validation success. If opts.constrain is true, * the input value will be constrained to valid characters. */ export const keydownValidator: Action< InputElement, { constrain?: boolean; } > = (node, opts) => { // prevent duplicate listeners if (node.dataset.validateKeydown !== undefined) return; node.dataset.validateKeydown = 'true'; node.addEventListener('keydown', async (evt: Event) => { const event = evt as KeyboardEvent; if (event.key.length > 1) return; // ignore non-character keys const valid = await validateInput(node, getNodeValue(node) + event.key); if (!valid && opts.constrain) { event.preventDefault(); } }); }; /** * keyupValidator attaches a keydown event listener to an input element that will * process the input value according to any attached validation rules, triggering * the `validate` event with the input value and validation state. */ export const keyupValidator: Action = (el) => { const node = el as HTMLInputElement; // prevent duplicate listeners if (node.dataset.validateKeyup !== undefined) return; node.dataset.validateKeyup = 'true'; node.addEventListener('keyup', (event: KeyboardEvent) => { if (event.key.length > 1) return; // ignore non-character keys validateInput(node); }); }; /** * inputValidator attaches an oninput event listener to an input element that will * process the input value according to any attached validation rules, triggering * the `validate` event with the input value and validation state. */ export const inputValidator: Action = (el) => { const node = el as HTMLInputElement; // prevent duplicate listeners if (node.dataset.validateInput !== undefined) return; node.dataset.validateInput = 'true'; node.addEventListener('input', () => { validateInput(node); }); }; /** * liveValidator attaches multiple event listeners to an input element that will * process the input value according to any attached validation rules, triggering * the `validate` event with the input value and validation state. liveValidator * supports the constrain option without introducing edge cases with text selection. */ export const liveValidator: Action< InputElement, { constrain?: boolean; } > = (node, opts) => { // prevent duplicate listeners if (node.dataset.validateLive !== undefined) return; node.dataset.validateLive = 'true'; const id = getid(node); if (id === -1) { console.warn('liveValidator: no validator ID found', node); return; } // propagateState updates the stored state of the input element. const propagateState = (e: InputElement) => { lastState[id] = { value: getNodeValue(e) }; if (e instanceof HTMLInputElement || e instanceof HTMLTextAreaElement) { lastState[id].selectionStart = e.selectionStart || 0; lastState[id].selectionEnd = e.selectionEnd || 0; } else if (e instanceof HTMLDivElement) { const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); const preRange = range.cloneRange(); preRange.selectNodeContents(e); preRange.setEnd(range.startContainer, range.startOffset); lastState[id].selectionStart = preRange.toString().length; lastState[id].selectionEnd = lastState[id].selectionStart + range.toString().length; } } }; // revertToState reverts the input element to a previous state. const revertToState = (e: InputElement, state: InputState) => { setNodeValue(node, state.value); if ( (e instanceof HTMLInputElement || e instanceof HTMLTextAreaElement) && state.selectionStart !== undefined && state.selectionEnd !== undefined ) { e.setSelectionRange(state.selectionStart, state.selectionEnd); } else if (e instanceof HTMLDivElement) { const selection = window.getSelection(); if (selection) { const range = document.createRange(); range.selectNodeContents(e); range.setStart(e.firstChild || e, state.selectionStart || 0); range.setEnd(e.firstChild || e, state.selectionEnd || 0); selection.removeAllRanges(); selection.addRange(range); } } }; // keydownHandler is used to track the last state of the input element. const keydownHandler = (event: Event) => { if (event.target) { propagateState(event.target as InputElement); } }; // inputHandler is used to validate the input element. const inputHandler = async (event: Event) => { const n = event.target as InputElement; const state = lastState[id]; if (!state || !event.target) return; // if the input value is the same as the last state, do nothing if (getNodeValue(n) === state.value) return; // if the input value is different, validate it const valid = await validateInput(n); if (!valid && opts.constrain) { revertToState(n, state); event.preventDefault(); } }; node.addEventListener('keydown', keydownHandler); node.addEventListener('input', inputHandler); }; /** * submitValidator attaches a submit event listener to a form element that will * process the input values of all children according to any attached validation * rules, triggering the validate event for individual input elements * depending on validation success. Similarly, the validate event * event will be triggered on the form element itself. */ export const submitValidator: Action< HTMLFormElement, undefined, { onvalidate: (e: FormValidatorEvent) => void; } > = (form) => { form.setAttribute('novalidate', 'true'); // prevent duplicate listeners if (form.dataset.validateSubmit !== undefined) return; form.dataset.validateSubmit = 'true'; form.addEventListener('submit', async (event) => { const valid = await validateForm(form); if (!valid) { event.preventDefault(); event.stopImmediatePropagation(); } }); }; /** disableFormDefault helper action prevents the default form submission behavior and disables all browser validation. */ export const disableFormDefault: Action = (form) => { form.setAttribute('novalidate', 'true'); form.addEventListener('submit', (event) => { event.preventDefault(); }); }; /** * validateInput checks an input element's value against its validation rules, triggering * the validate events with the current value and validation state, updating the input's * dataset accordingly. If opts.autovalOnInvalid is true, the input will be revalidated on * on keyup automatically from now on. */ export const validateInput = async ( input: InputElement, valueOverride?: string ): Promise => { const valid = await isInputValid(input, valueOverride); input.dataset[DATA_STATE] = valid ? VALIDATOR_VALID : VALIDATOR_INVALID; input.dispatchEvent( new InputValidatorEvent('validate', { detail: { value: getNodeValue(input), valid } }) ); // if invalid, consider autovalOnInvalid if (!valid) { const opts = getopts(input); if (opts?.autovalOnInvalid) { keyupValidator(input); } } return valid; }; /** * validateForm checks all input elements in a form element against their validation rules, * triggering valid or invalid events for individual input elements depending on validation * success. Similarly, valid and invalid events will be triggered on the form element. */ export const validateForm = async (form: HTMLFormElement): Promise => { const inputs = form.querySelectorAll(QUERY_INPUTS); const payload: FormValidatorPayload = { invalidInputs: [], validInputs: [], valid: false }; const results = await Promise.allSettled( Array.from(inputs).map(async (input): Promise => { const valid = await validateInput(input as InputElement); if (!valid) { payload.invalidInputs.push(input as InputElement); } else { payload.validInputs.push(input as InputElement); } return valid; }) ); const valid = results.every((result) => result.status === 'fulfilled' && result.value === true); payload.valid = valid; form.dispatchEvent(new FormValidatorEvent('validate', { detail: payload })); return valid; }; /** isInputValid checks an input element's value against its validation rules. */ export const isInputValid = async ( input: InputElement, valueOverride?: string ): Promise => { const node = input as HTMLInputElement; const val = valueOverride || getNodeValue(node); const opts = getopts(input); if (!opts) { console.warn('input has no validation rules', node); return true; } // if input is required and is a checkbox, return true if checked if (opts.required && node.getAttribute('type') === 'checkbox' && !node.checked) { console.debug('input is required but unchecked', node); return false; } // check if input is required and/or matches a pattern and/or raw validator if (!(await isValueValid(val, opts))) { console.debug('input basic rules failed', input); return false; } // if input has a custom validator, check the input against the custom validator if (opts.func !== undefined) { const result = await opts.func(node); if (!result) { console.debug('input is invalid', 'val:', val, 'baseval:', opts.baseval, input); return false; } } console.debug('input is valid', 'val:', val, 'baseval:', opts.baseval, 'opts', opts, input); return true; }; /** isFormValid checks all input elements in a form element against their validation rules. */ export const isFormValid = async (form: HTMLFormElement): Promise => { const inputs = form.querySelectorAll(QUERY_INPUTS); const results = await Promise.allSettled( Array.from(inputs).map(async (input): Promise => { return await isInputValid(input as InputElement); }) ); const valid = results.every((result) => result.status === 'fulfilled' && result.value === true); return valid; }; /** * isValueValid checks a value against a set of validation rules. Note: node-based * custom validator functions are not supported by this function. */ export const isValueValid = async (val: string, opts: ValidatorOptions): Promise => { console.debug('isValueValid', `val="${val}"`, 'opts:', opts); // if input is required and empty, return false if (opts.required && (val === '' || val === opts.baseval)) { console.debug('input is required but empty', 'val:', val, 'baseval:', opts.baseval); return false; } // if input has a valfunc validator, check the input against the valfunc if (opts.valfunc !== undefined) { const result = await opts.valfunc(val); if (!result) { console.debug('input is invalid', 'val:', val, 'baseval:', opts.baseval); return false; } } // if input has a pattern validator, check the input against the pattern if (opts.pattern !== undefined && val !== '' && val !== opts.baseval) { if (!opts.pattern.test(val)) { console.debug('input does not match regex', 'val:', val, 'regex:', opts.pattern); return false; } } // if input has a minimum length, check the input against the minimum length if (opts.minlength !== undefined && val.length < opts.minlength) { console.debug('input is too short', 'val:', val, 'minlength:', opts.minlength); return false; } // if input has a maximum length, check the input against the maximum length if (opts.maxlength !== undefined && val.length > opts.maxlength) { console.debug('input is too long', 'val:', val, 'maxlength:', opts.maxlength); return false; } // if input has an exact length, check the input against the exact length if (opts.length !== undefined && val.length !== opts.length) { console.debug('input is not the correct length', 'val:', val, 'length:', opts.length); return false; } console.debug('input is valid', 'val:', val, 'baseval:', opts.baseval); return true; }; /** * getid returns the validator ID attached to an input element. Returns -1 if * no ID is attached. */ export const getid = (input: InputElement): number => { const idstr = input.dataset[DATA_ID] || '-1'; const id = parseInt(idstr); return id; }; /** getopts returns the validation rules attached to an input element. */ export const getopts = (input: InputElement): ValidatorOptions | undefined => { const id = getid(input); return validators[id]; };