UNPKG

svelte-common-hooks

Version:
278 lines (277 loc) 9.99 kB
import { z } from 'zod/v4'; /** * The helper to create attributes for a form field * @example * ```svelte * <script lang="ts"> * import type { HTMLInputAttributes } from 'svelte/elements'; * import { createAttribute } from 'svelte-common-hooks'; * const attribute = createAttribute<HTMLInputAttributes>({ * type: 'text', * placeholder: 'Enter your name', * required: true, * customAttribute: 'customAttribute', * }); * </script> * ``` * @param attribute the attributes that will be applied to the form field */ export function createAttribute(attribute) { return attribute; } /** * Creates and manages the state of a form based on a Zod schema. * * @example * ```svelte * <script lang="ts"> * import { createFormState } from 'svelte-common-hooks'; * import { z } from 'zod/v4'; * const formState = createFormState({ * schema: z.object({ * name: z.string().min(1), * email: z.string().email(), * age: z.number().min(18) * }), * // optionally set the initial value * initial: { * name: '', * email: '', * age: 0 * }, * // optionally append more attribute to the form field * attribute: { * name: createAttribute<HTMLInputAttributes>({ * type: 'text', * placeholder: 'Enter your name', * required: true, * customAttribute: 'customAttribute', * }), * email: createAttribute<HTMLInputAttributes>({ * type: 'email', * required: true * }), * age: createAttribute<HTMLInputAttributes>({ * type: 'number', * required: true * }) * } * }); * </script> * <form action="" method="post"> * <div> * <label for="name">Name</label> * <input bind:value={formState.value.name} {...formState.attribute.name} /> * {#if formState.result.name.errors.length} * {#each formState.result.name.errors as error} * <span>{error}</span> * {/each} * {/if} * </div> * <div> * <label for="email">Email</label> * <input bind:value={formState.value.email} {...formState.attribute.email} /> * {#if formState.result.email.errors.length} * {#each formState.result.email.errors as error} * <span>{error}</span> * {/each} * {/if} * </div> * <div> * <label for="age">Age</label> * <input bind:value={formState.value.age} {...formState.attribute.age} /> * {#if formState.result.age.errors.length} * {#each formState.result.age.errors as error} * <span>{error}</span> * {/each} * {/if} * </div> * </form> * ``` * @template T - The Zod object schema defining the structure of the form. * @template Initial - The initial values of the form state, inferred from the schema. * @param {FormStateProps<T, Initial>} props - The configuration properties for the form state. * @property {FormStateValue<Initial>} value - The current values of the form fields. * @property {FormStateAttribute<T>} attribute - The attributes for each form field. * @property {FormStateConfig<T>} result - The validation result for each form field. * @property {Function} addErrors - Function to add errors to a specific form field. * @property {Function} setValue - Function to set the value of a specific form field. * @property {Function} validate - Function to validate a specific form field. * @property {Function} validateAll - Function to validate the entire form. */ export function createFormState(props) { const keys = Object.keys(props.schema.shape); /** * the reactive state of the form, the value is the initial value of the form state, if none is provided it will be null */ let value = $state(keys.reduce((acc, value) => { if (props.initial && value in props.initial) acc[value] = props.initial[value]; else acc[value] = null; return acc; }, {})); /** * The validation result for each form field, the value is an object that contains the errors and hasError for each form field */ const result = $state(keys.reduce((acc, value) => { acc[value] = { errors: [], hasError: false }; return acc; }, {})); /** * The attributes for each form field, the value is an object that contains the attributes for each form field */ const attribute = $state(keys.reduce((acc, v) => { const userDefinedOnInput = props.attribute?.[v]?.oninput; const userDefinedOnBlur = props.attribute?.[v]?.onblur; if (props.attribute?.[v]) { delete props.attribute[v].name; delete props.attribute[v].id; delete props.attribute[v]['aria-invalid']; delete props.attribute[v].oninput; delete props.attribute[v].onblur; } acc[v] = { ...(props.attribute?.[v] ?? {}), name: v, id: v, 'aria-invalid': false, oninput: (event) => { if (result?.[v]?.hasError === false) return userDefinedOnInput?.(event); const value = 'value' in event.currentTarget ? event.currentTarget.value : undefined; const validated = props.schema.pick({ [v]: true }).safeParse({ [v]: value }); if (!validated.success) { result[v].errors = validated.error.issues.map((v) => v.message); result[v].hasError = true; attribute[v]['aria-invalid'] = true; } else { result[v].errors = []; result[v].hasError = false; attribute[v]['aria-invalid'] = false; } userDefinedOnInput?.(event); }, onblur: (event) => { const value = 'value' in event.currentTarget ? event.currentTarget.value : undefined; const validated = props.schema.pick({ [v]: true }).safeParse({ [v]: value }); if (!validated.success) { result[v].errors = validated.error.issues.map((v) => v.message); result[v].hasError = true; attribute[v]['aria-invalid'] = true; } else { result[v].errors = []; result[v].hasError = false; attribute[v]['aria-invalid'] = false; } userDefinedOnBlur?.(event); } }; return acc; }, {})); /** * Add errors to a specific form field. * * @param {keyof z.infer<T>} key - The key of the form field to add errors to. * @param {string[]} errors - The errors to add. */ const addErrors = (key, errors) => { result[key].errors = errors; result[key].hasError = true; attribute[key]['aria-invalid'] = true; }; /** * Set the value of a specific form field. * * @param {K} key - The key of the form field to set the value of. * @param {typeof value[K]} newValue - The new value to set. * @param {Object} [config] - The configuration object. * @param {boolean} [config.validateFirst=true] - Whether to validate the field before setting the value. * @param {boolean} [config.addErrorIfInvalid=true] - Whether to add errors if the field is invalid. */ const setValue = (key, newValue, config = { validateFirst: true, addErrorIfInvalid: true }) => { if (!config.validateFirst) return (value[key] = newValue); if (!(key in keys)) return; const validated = props.schema.pick({ [key]: true }).safeParse({ [key]: newValue }); if (validated.success) value[key] = newValue; if (config.addErrorIfInvalid) { addErrors(key, validated.success ? [] : validated.error.issues.map((v) => v.message)); } }; /** * Validate a specific form field. */ const validate = (key) => { const parseResult = props.schema .pick({ [key]: true }) .safeParse({ [key]: value[key] }); if (!parseResult.success) { addErrors(key, parseResult.error.issues.map((v) => v.message)); } return parseResult; }; /** * Validate the entire form. * * @param {ExtendedData} extendedData - The data to validate, if none is provided it will validate the current form state. * @returns {ParseResult<Initial>} The result of the validation. */ const validateAll = (extendedData) => { const parseAllResult = props.schema.safeParse(extendedData ?? value); if (!parseAllResult.success) { parseAllResult.error.issues.forEach((v) => { result[v.path[0]].errors = [v.message]; result[v.path[0]].hasError = true; attribute[v.path[0]]['aria-invalid'] = true; }); } return parseAllResult; }; const resetError = () => { Object.keys(result).forEach((key) => { result[key].errors = []; result[key].hasError = false; attribute[key]['aria-invalid'] = false; }); }; const setErrors = (newErrors) => { Object.keys(newErrors).forEach((key) => { const theError = newErrors[key]; const hasError = theError !== undefined && Array.isArray(theError) && theError.length > 0; result[key].errors = theError ?? []; result[key].hasError = hasError; attribute[key]['aria-invalid'] = hasError; }); }; return { get value() { return value; }, set value(newValue) { value = newValue; }, get attribute() { return attribute; }, get result() { return result; }, addErrors, setValue, validate, validateAll, resetError, setErrors }; }