From 298ce6954b1d604ec324803b2d15b14a7eb0a447 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Sun, 13 Apr 2025 07:56:23 -0700 Subject: [PATCH] partially refactor components to ui package --- eslint.config.js | 3 + index.ts | 342 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 22 +++ 3 files changed, 367 insertions(+) create mode 100644 eslint.config.js create mode 100644 index.ts create mode 100644 package.json diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..9cadde2 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,3 @@ +import { config } from '@repo/eslint-config/index.js'; + +export default config; diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..86f9514 --- /dev/null +++ b/index.ts @@ -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 | 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)$`); + +let nextValidatorID = 0; +const validators: 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) => { + 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 = (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 = (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; +}; + +// 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]; +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..02144e2 --- /dev/null +++ b/package.json @@ -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" + } +}