diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index 5adfabd77..a21ebb566 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -1,7 +1,9 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useAnimate, useReducedMotion } from "motion/react"; import { useTranslations } from "next-intl"; +import { useEffect } from "react"; import { type ControllerRenderProps, type FieldPath, @@ -21,6 +23,7 @@ import { getZodInputParams, type InputParams, } from "../../lib/helpers/auto-form"; +import { SHAKE_KEYFRAMES, SHAKE_TRANSITION } from "../../lib/motion"; import { Button } from "../ui/button"; import { DialogClose, DialogFooter, useDialog } from "../ui/dialog"; import { Field } from "../ui/field"; @@ -60,6 +63,28 @@ export interface ItemAutoFormComponentProps { }; } +function AutoFormField({ + invalid, + submitCount, + ...props +}: React.ComponentProps & { + invalid: boolean; + submitCount: number; +}) { + const [scope, animate] = useAnimate(); + const shouldReduceMotion = useReducedMotion(); + + useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler + if (!invalid || shouldReduceMotion || !scope.current) return; + + // Restart the shake on every error, mirroring the macOS shake. + animate(scope.current, SHAKE_KEYFRAMES, SHAKE_TRANSITION); + }, [invalid, submitCount, shouldReduceMotion, animate, scope]); + + return ; +} + export type AutoFormOnSubmit< T extends z.ZodObject, TContext = unknown, @@ -169,9 +194,10 @@ export function AutoForm< name={item.id} render={({ field, fieldState }) => { return ( - {item.component({ field, @@ -215,7 +241,7 @@ export function AutoForm< : undefined, }, })} - + ); }} /> diff --git a/packages/vitnode/src/lib/motion.ts b/packages/vitnode/src/lib/motion.ts new file mode 100644 index 000000000..7b4f0e9d9 --- /dev/null +++ b/packages/vitnode/src/lib/motion.ts @@ -0,0 +1,2 @@ +export const SHAKE_KEYFRAMES = { x: [0, -8, 8, -6, 6, -3, 3, 0] }; +export const SHAKE_TRANSITION = { duration: 0.4, ease: "easeInOut" as const }; diff --git a/packages/vitnode/src/views/auth/sign-in/form/form.tsx b/packages/vitnode/src/views/auth/sign-in/form/form.tsx index e77477ea0..7cae30a4a 100644 --- a/packages/vitnode/src/views/auth/sign-in/form/form.tsx +++ b/packages/vitnode/src/views/auth/sign-in/form/form.tsx @@ -1,11 +1,13 @@ "use client"; import { AlertCircle } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; import { useTranslations } from "next-intl"; import { AutoForm } from "@/components/form/auto-form"; import { AutoFormInput } from "@/components/form/fields/input"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { SHAKE_KEYFRAMES, SHAKE_TRANSITION } from "@/lib/motion"; import { Link } from "@/lib/navigation"; import { useFormSignIn } from "./use-form"; @@ -18,16 +20,22 @@ export const FormSignIn = ({ isEmail: boolean; }) => { const t = useTranslations("core.auth.sign_in"); + const shouldReduceMotion = useReducedMotion(); const { onSubmit, error, formSchema } = useFormSignIn({ isAdmin }); return (
{error && ( - - - {t(`errors.${error}.title`)} - {t(`errors.${error}.desc`)} - + + + + {t(`errors.${error}.title`)} + {t(`errors.${error}.desc`)} + + )}