partially refactor components to ui package
This commit is contained in:
3
eslint.config.js
Normal file
3
eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { config } from '@repo/eslint-config/index.js';
|
||||
|
||||
export default config;
|
||||
342
index.ts
Normal file
342
index.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
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];
|
||||
};
|
||||
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@repo/validate",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"module": "index.ts",
|
||||
"main": "index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.ts",
|
||||
"svelte": "./index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"eslint": "^9.24.0",
|
||||
"svelte": "^5.25.3"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user