diff --git a/src/hooks/validation.tsx b/src/hooks/validation.tsx new file mode 100644 index 0000000..3446fd8 --- /dev/null +++ b/src/hooks/validation.tsx @@ -0,0 +1,154 @@ +import { + createContext, + createEffect, + createSignal, + FlowComponent, + onCleanup, + onMount, + useContext, +} from "solid-js"; +import { createStore } from "solid-js/store"; + +const makeStore = () => + createStore({ + valid: true as boolean, + values: {} as Record, + errors: {} as Record, + }); +type Store = ReturnType; +const ValidationContext = createContext(); + +export const createValidation = function () { + const store = makeStore(); + const isValid = createEffect(() => { + store[1]("valid", Object.keys(store[0].errors).length === 0); + }); + + const ContextProvider: FlowComponent = (props) => ( + + {props.children} + + ); + const result = [ContextProvider, store[0]] as [ + typeof ContextProvider, + typeof store[0] + ]; + return result; +}; + +const EMPTY_OBJECT = {}; + +export const validateInput = function ({ + value: getValue, + listen, +}: { + value?: () => any; + listen?: { + input?: boolean; + blur?: boolean; + change?: boolean; + observer?: boolean; + }; +} = {}) { + // TODO: Default listen values could be set from the ValidationContext + listen = listen || { input: true, blur: true, change: false, observer: true }; + const store = useContext(ValidationContext); + const [validity, setValidity] = createSignal( + { valid: true } as any, + { equals: false } + ); + + let name = ""; + let value: any = null; + let init = true; + let lastBadInput = false; + let isCheckbox = false; + let el: HTMLInputElement = undefined!; + + const onChange = function () { + if (!el) return; + let nextValue = isCheckbox ? el.checked : el.value; + const validity_ = el.validity || EMPTY_OBJECT; + if ( + init || + nextValue !== value || + // Update state on badInput (value stays unchanged on badInput), but only update the state once + validity_.badInput != lastBadInput + ) { + name = name || el.name || el.id; + setValidity(validity_); + lastBadInput = validity_.badInput; + + if (validity_.valid) { + store && store[1]("errors", name, undefined as any); + } else { + store && store[1]("errors", name, validity_); + } + + value = nextValue; + store && store[1]("values", name, value === undefined ? null : value); + } + + init = false; + }; + + const directive = function (el_: HTMLInputElement) { + el = el_; + isCheckbox = el.type === "checkbox"; + + onCleanup(function () { + if (name) { + store && store[1]("errors", name, undefined as any); + store && store[1]("values", name, undefined); + } + }); + + if (getValue) { + createEffect(function () { + getValue(); + onChange(); + }); + } + + if (listen?.observer) { + const observer = new MutationObserver(onChange); + + onMount(function () { + observer.observe(el, { attributes: true }); + }); + + onCleanup(function () { + observer.disconnect(); + }); + } + if (listen?.input) { + el.addEventListener("input", function () { + onChange(); + }); + } + if (listen?.change) { + el.addEventListener("change", function () { + onChange(); + }); + } + if (listen?.blur) { + el.addEventListener("blur", function () { + requestAnimationFrame(onChange); + }); + } + }; + + return [directive, validity, onChange] as [ + typeof directive, + typeof validity, + typeof onChange + ]; +}; + +declare module "solid-js" { + namespace JSX { + interface Directives { + validate: boolean; + } + } +}