'use client'; import { useCallback } from 'react'; import type { FieldErrors, FieldValues } from 'react-hook-form'; // react-hook-form's handleSubmit is generic across the input vs. // transformed types. We don't need the strictness here - the wrapper // just passes its handler through to whatever handleSubmit the caller // gave us. Use a loose type so 2-arg and 3-arg useForm() both work. type AnyHandleSubmit = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any onValid: any, // eslint-disable-next-line @typescript-eslint/no-explicit-any onInvalid?: any, ) => (e?: React.BaseSyntheticEvent) => Promise; /** * Find the nearest scrolling ancestor of a node - accounts for the * common case of forms rendered inside a Sheet / Dialog body that owns * its own overflow-y. Falls back to `window` when no ancestor scrolls. */ function findScrollContainer(el: HTMLElement | null): HTMLElement | null { let cur: HTMLElement | null = el?.parentElement ?? null; while (cur) { const style = window.getComputedStyle(cur); const overflowY = style.overflowY; if ((overflowY === 'auto' || overflowY === 'scroll') && cur.scrollHeight > cur.clientHeight) { return cur; } cur = cur.parentElement; } return null; } /** * Wrap react-hook-form's `handleSubmit` so validation failures scroll * the first errored field into view and focus it. Critical on tall * drawers / dialogs where the failing field is below the fold - * without this the user is dropped at the top of the form with no * indication of what failed. * * Usage: * ``` * const { handleSubmit, formState: { errors }, ... } = useForm(...); * const onSubmit = useFormScrollToError(handleSubmit, errors); *
...
* ``` * * `errors` is taken from `formState` so the hook reads the FIRST key * (insertion order matches field render order in practice). */ export function useFormScrollToError( handleSubmit: AnyHandleSubmit, errors: FieldErrors, ) { return useCallback( // eslint-disable-next-line @typescript-eslint/no-explicit-any (onValid: (data: any) => void | Promise) => { return handleSubmit(onValid, () => { // react-hook-form calls this on validation failure. We already // have `errors` from formState - read the first key and scroll // its DOM node into view. const firstName = Object.keys(errors)[0]; if (!firstName) return; // Find by `name` first (most input components forward `name` // from `register`), then by `id` (fallback for custom Inputs). const node = (document.querySelector(`[name="${firstName}"]`) as HTMLElement | null) ?? (document.getElementById(firstName) as HTMLElement | null); if (!node) return; const container = findScrollContainer(node); if (container) { // Manually compute position so we scroll inside the // container, not the page. const cRect = container.getBoundingClientRect(); const nRect = node.getBoundingClientRect(); const offset = nRect.top - cRect.top + container.scrollTop - cRect.height / 2; container.scrollTo({ top: offset, behavior: 'smooth' }); } else { node.scrollIntoView({ block: 'center', behavior: 'smooth' }); } if (typeof node.focus === 'function') { // Defer focus until after the smooth scroll has started so // the focus ring is visible. window.setTimeout(() => node.focus({ preventScroll: true }), 50); } }); }, [handleSubmit, errors], ); }