import type { Action } from 'svelte/action'; // InputElement is a union of all input types that can be validated. export type InputElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; // InputNodeList is a union of all node lists that can be validated. export type InputNodeList = NodeListOf | NodeListOf | []; // ValidatorOptions configures the behavior of a validator. export type ValidatorOptions = { required?: boolean; // required is a flag that indicates whether the input is required. pattern?: RegExp; // pattern is a regex pattern that the input value must match. minlength?: number; // minlength is the minimum length of the input value. maxlength?: number; // maxlength is the maximum length of the input value. length?: number; // length is the exact length of the input value. type?: 'email' | 'phone' | 'score'; // NOT IMPLEMENTED. type is a predefined validation type. baseval?: string; // baseval is a value that the input value must not match (an alternate zero-value). autovalOnInvalid?: boolean; // autovalOnInvalid is a flag that automatically adds a keyup listener to the input on invalid. func?: (node: InputElement) => boolean | Promise; 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) => { console.log('validate rules attached', 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) => { console.log('update validator options', node, 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; } > = (el, opts) => { const node = el as HTMLInputElement; // prevent duplicate listeners if (node.dataset.validateKeydown !== undefined) return; node.dataset.validateKeydown = 'true'; node.addEventListener('keydown', async (event: KeyboardEvent) => { if (event.key.length > 1) return; // ignore non-character keys const valid = await validateInput(node, node.value + 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; } > = (el, opts) => { const node = el as HTMLInputElement; // 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: HTMLInputElement) => { lastState[id] = { value: e.value, selectionStart: e.selectionStart || 0, selectionEnd: e.selectionEnd || 0 }; }; // revertToState reverts the input element to a previous state. const revertToState = (e: HTMLInputElement, state: InputState) => { e.value = state.value; e.setSelectionRange(state.selectionStart, state.selectionEnd); }; // keydownHandler is used to track the last state of the input element. const keydownHandler = (event: KeyboardEvent) => { if (event.target) { propagateState(event.target as HTMLInputElement); } }; // inputHandler is used to validate the input element. const inputHandler = async (event: Event) => { const n = event.target as HTMLInputElement; const state = lastState[id]; if (!state || !event.target) return; // if the input value is the same as the last state, do nothing if (n.value === 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: input.value, 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 || node.value.trim(); 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.log('input is invalid', 'val:', val, 'baseval:', opts.baseval); 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]; };