svelte-common-hooks
Version:
Common hooks for Svelte
278 lines (277 loc) • 9.99 kB
JavaScript
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
};
}