diff --git a/src/components/expenses/expense-form-dialog.tsx b/src/components/expenses/expense-form-dialog.tsx index 0c330184..9e801a93 100644 --- a/src/components/expenses/expense-form-dialog.tsx +++ b/src/components/expenses/expense-form-dialog.tsx @@ -11,6 +11,8 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; +import { FormErrorSummary } from '@/components/forms/form-error-summary'; +import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error'; import { Select, SelectContent, @@ -75,6 +77,11 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi paymentStatus: 'unpaid', }, }); + // Scroll-to-first-error wrapper around handleSubmit. On validation + // failure it auto-scrolls + focuses the first errored input so reps + // can see what failed without hunting (especially important on tall + // drawer-based forms like this one). + const onSubmitWithScroll = useFormScrollToError(handleSubmit, errors); useEffect(() => { if (open && expense) { @@ -208,7 +215,19 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi {isEdit ? 'Edit Expense' : 'New Expense'} -
+ +
{ + errors: FieldErrors; + /** + * Optional override map for human-readable field labels. Falls back + * to the schema-supplied message label or the raw field name. + */ + labels?: Partial>; + className?: string; +} + +/** + * Top-of-form validation summary. Renders only when ≥2 fields have + * errors; a single-error form is handled by the + * `useFormScrollToError` hook (no banner needed). Each entry is an + * anchor link that scrolls + focuses the offending input on click. + */ +export function FormErrorSummary({ + errors, + labels, + className, +}: FormErrorSummaryProps) { + const entries = Object.entries(errors).filter(([, err]) => err != null); + if (entries.length < 2) return null; + + function onJump(name: string) { + const node = + (document.querySelector(`[name="${name}"]`) as HTMLElement | null) ?? + (document.getElementById(name) as HTMLElement | null); + if (!node) return; + node.scrollIntoView({ block: 'center', behavior: 'smooth' }); + if (typeof node.focus === 'function') { + window.setTimeout(() => node.focus({ preventScroll: true }), 50); + } + } + + return ( +
+
+ + Please fix the following before submitting: +
+
    + {entries.map(([name, err]) => { + const label = + (labels as Record | undefined)?.[name] ?? + (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' + ? err.message + : name); + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/src/hooks/use-form-scroll-to-error.ts b/src/hooks/use-form-scroll-to-error.ts new file mode 100644 index 00000000..e64ba7d9 --- /dev/null +++ b/src/hooks/use-form-scroll-to-error.ts @@ -0,0 +1,90 @@ +'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. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyHandleSubmit = ( + onValid: 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], + ); +}