343 lines
12 KiB
TypeScript
343 lines
12 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?: 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<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)$`);
|
|
|
|
let nextValidatorID = 0;
|
|
const validators: Record<number, ValidatorOptions> = {};
|
|
|
|
// 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) => {
|
|
const valid = await validateInput(node, node.value + event.key);
|
|
if (event.key.length === 1 && !valid) {
|
|
if (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', () => {
|
|
validateInput(node);
|
|
});
|
|
};
|
|
|
|
// 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;
|
|
};
|
|
|
|
// getopts returns the validation rules attached to an input element.
|
|
export const getopts = (input: InputElement): ValidatorOptions | undefined => {
|
|
const idstr = input.dataset[DATA_ID] || '-1';
|
|
const id = parseInt(idstr);
|
|
return validators[id];
|
|
};
|