vuetensils
Version:
A 'naked' component library for building accessible, lightweight, on-brand applications.
178 lines (147 loc) • 4.43 kB
JavaScript
import { reactive, watch, computed, nextTick } from 'vue';
/**
* @typedef {import('vue').Ref<HTMLFormElement>} FormRef
* @typedef {import('vue').ComputedRef} ComputedRef
* @typedef {string | ((string) => string)} ErrorMessage
* @typedef {{
* value: string
* valid: boolean
* dirty: boolean
* invalid: {
* type: boolean
* required: boolean
* minlength: boolean
* maxlength: boolean
* min: boolean
* max: boolean
* pattern: boolean
* }
* errors: any[]
* }} FormInputState
*/
/**
* @param {FormRef} formRef
* @param {{
* errors?: {
* type?: ErrorMessage
* required?: ErrorMessage
* minlength?: ErrorMessage
* maxlength?: ErrorMessage
* min?: ErrorMessage
* max?: ErrorMessage
* pattern?: ErrorMessage
* }
* }} options
*/
const useForm = (formRef, options = {}) => {
const baseState = {
invalid: false,
dirty: false,
/** @type {boolean | ComputedRef} */
error: false,
/** @type {Record<string, FormInputState>} */
inputs: {},
};
/**
* @typedef {object} FormMethods
* @property {Function} clear Assigns all form input values to empty strings
* @property {Function} validate
* @property {Function} reset Resets form dirty state and calls validate()
*/
/**
* @type {typeof baseState & Partial<FormMethods>}
*/
const state = reactive(baseState);
const errors = new Map(Object.entries(options.errors || {}));
const validate = async (form) => {
if (!form) return;
await nextTick();
state.invalid = !form.checkValidity();
/** @type {NodeListOf<HTMLInputElement>} */
const els = form.querySelectorAll('input, textarea, select');
/** @type {typeof baseState.inputs} */
const inputs = {};
els.forEach((input) => {
const { name, id, validity } = input;
const inputId = name || id;
if (!inputId) return;
const inputStatus = {
value: input.value,
valid: validity.valid,
dirty: false,
invalid: {
type: validity.typeMismatch,
required: validity.valueMissing,
minlength: validity.tooShort,
maxlength: validity.tooLong,
min: validity.rangeOverflow,
max: validity.rangeUnderflow,
pattern: validity.patternMismatch,
},
errors: [],
};
errors.forEach((value, key) => {
if (!inputStatus.invalid[key]) return;
const errorHandler = errors.get(key);
const attrName = key.replace('length', 'Length'); // for minLength and maxLength
const errorMessage =
typeof errorHandler === 'string'
? errorHandler
: errorHandler(input[attrName]);
inputStatus.errors.push(errorMessage);
});
// Weird reactivity issue means we have to handle dirty state this way
inputStatus.dirty = state.inputs[inputId]
? state.inputs[inputId].dirty
: false;
inputs[inputId] = inputStatus;
});
state.inputs = inputs;
};
const onInput = () => validate(formRef.value);
const onBlur = (event) => {
validate(formRef.value);
const input = event.target;
const inputId = input.name || input.id;
state.dirty = true;
if (!inputId) return;
if (!state.inputs[inputId]) return;
state.inputs[inputId].dirty = true;
};
const observer = new MutationObserver(onInput);
watch(formRef, async (form, prev, onInvalidate) => {
if (!form) return;
await nextTick();
validate(form);
form.addEventListener('input', onInput);
form.addEventListener('blur', onBlur, {
capture: true,
});
observer.observe(form, {
childList: true,
subtree: true,
// attributes: true, // TODO: hy does this cause infinite loop?
});
onInvalidate(() => {
form.removeEventListener('input', onInput);
form.removeEventListener('blur', onBlur);
observer.disconnect();
});
});
state.error = computed(() => state.invalid && state.dirty);
state.validate = () => validate(formRef.value);
state.reset = () => {
state.dirty = false;
validate(formRef.value);
};
state.clear = () => {
const form = formRef.value;
/** @type {NodeListOf<HTMLInputElement>} */
const inputs = form.querySelectorAll('input, textarea, select');
inputs.forEach((input) => {
input.value = '';
});
};
return state;
};
export default useForm;