refactor: scroll to first error when submitting a form

This commit is contained in:
Nicolas Meienberger 2026-02-18 19:33:58 +01:00
parent 505f60a8a1
commit d34bd53275
5 changed files with 35 additions and 4 deletions

View file

@ -0,0 +1,10 @@
import { useCallback } from "react";
export function useScrollToFormError() {
return useCallback(() => {
setTimeout(() => {
const firstError = document.querySelector("[data-slot='form-message']");
firstError?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 50);
}, []);
}

View file

@ -1,6 +1,7 @@
import { arktypeResolver } from "@hookform/resolvers/arktype"; import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { Form } from "~/client/components/ui/form"; import { Form } from "~/client/components/ui/form";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import type { BackupSchedule, Volume } from "~/client/lib/types"; import type { BackupSchedule, Volume } from "~/client/lib/types";
@ -30,6 +31,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
defaultValues: backupScheduleToFormValues(initialValues), defaultValues: backupScheduleToFormValues(initialValues),
}); });
const scrollToFirstError = useScrollToFormError();
const handleSubmit = useCallback( const handleSubmit = useCallback(
(data: InternalFormValues) => { (data: InternalFormValues) => {
const { const {
@ -100,7 +103,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
return ( return (
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(handleSubmit)} onSubmit={form.handleSubmit(handleSubmit, scrollToFirstError)}
className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]" className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]"
id={formId} id={formId}
> >

View file

@ -15,6 +15,7 @@ import {
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { notificationConfigSchemaBase } from "~/schemas/notifications"; import { notificationConfigSchemaBase } from "~/schemas/notifications";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { import {
CustomForm, CustomForm,
DiscordForm, DiscordForm,
@ -116,11 +117,16 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
}); });
const { watch } = form; const { watch } = form;
const scrollToFirstError = useScrollToFormError();
const watchedType = watch("type"); const watchedType = watch("type");
return ( return (
<Form {...form}> <Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}> <form
id={formId}
onSubmit={form.handleSubmit(onSubmit, scrollToFirstError)}
className={cn("space-y-4", className)}
>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"

View file

@ -20,6 +20,7 @@ import { SecretInput } from "../../../components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { COMPRESSION_MODES, repositoryConfigSchemaBase } from "~/schemas/restic"; import { COMPRESSION_MODES, repositoryConfigSchemaBase } from "~/schemas/restic";
import { Checkbox } from "../../../components/ui/checkbox"; import { Checkbox } from "../../../components/ui/checkbox";
import { import {
@ -96,10 +97,15 @@ export const CreateRepositoryForm = ({
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default"); const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
const { capabilities } = useSystemInfo(); const { capabilities } = useSystemInfo();
const scrollToFirstError = useScrollToFormError();
return ( return (
<Form {...form}> <Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}> <form
id={formId}
onSubmit={form.handleSubmit(onSubmit, scrollToFirstError)}
className={cn("space-y-4", className)}
>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"

View file

@ -22,6 +22,7 @@ import { volumeConfigSchemaBase } from "~/schemas/volumes";
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen"; import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms"; import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
export const formSchema = type({ export const formSchema = type({
@ -65,6 +66,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
const { getValues, watch } = form; const { getValues, watch } = form;
const { capabilities } = useSystemInfo(); const { capabilities } = useSystemInfo();
const scrollToFirstError = useScrollToFormError();
const watchedBackend = watch("backend"); const watchedBackend = watch("backend");
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null); const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
@ -102,7 +104,11 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
return ( return (
<Form {...form}> <Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}> <form
id={formId}
onSubmit={form.handleSubmit(onSubmit, scrollToFirstError)}
className={cn("space-y-4", className)}
>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"