469 lines
16 KiB
TypeScript
469 lines
16 KiB
TypeScript
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<HTMLInputElement> | NodeListOf<HTMLTextAreaElement> | [];
|
|
|
|
/** 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?: (node: InputElement) => boolean | Promise<boolean>;
|
|
valfunc?: (val: string) => boolean | Promise<boolean>;
|
|
};
|
|
|
|
/** 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<InputValidatorPayload> {}
|
|
/** 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<FormValidatorPayload> {}
|
|
|
|
/** 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<number, ValidatorOptions> = {};
|
|
const lastState: Record<number, InputState> = {};
|
|
|
|
/**
|
|
* 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<InputElement> = (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<InputElement> = (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<HTMLFormElement> = (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<boolean> => {
|
|
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<boolean> => {
|
|
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<boolean> => {
|
|
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<boolean> => {
|
|
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<boolean> => {
|
|
const inputs = form.querySelectorAll(QUERY_INPUTS);
|
|
|
|
const results = await Promise.allSettled(
|
|
Array.from(inputs).map(async (input): Promise<boolean> => {
|
|
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<boolean> => {
|
|
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];
|
|
};
|