Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions packages/vitnode/src/components/form/auto-form.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -60,6 +63,28 @@ export interface ItemAutoFormComponentProps {
};
}

function AutoFormField({
invalid,
submitCount,
...props
}: React.ComponentProps<typeof Field> & {
invalid: boolean;
submitCount: number;
}) {
const [scope, animate] = useAnimate<HTMLDivElement>();
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 <Field data-invalid={invalid} ref={scope} {...props} />;
}

export type AutoFormOnSubmit<
T extends z.ZodObject<z.ZodRawShape>,
TContext = unknown,
Expand Down Expand Up @@ -169,9 +194,10 @@ export function AutoForm<
name={item.id}
render={({ field, fieldState }) => {
return (
<Field
data-invalid={fieldState.invalid}
<AutoFormField
invalid={fieldState.invalid}
orientation="responsive"
submitCount={form.formState.submitCount}
>
{item.component({
field,
Expand Down Expand Up @@ -215,7 +241,7 @@ export function AutoForm<
: undefined,
},
})}
</Field>
</AutoFormField>
);
}}
/>
Expand Down
2 changes: 2 additions & 0 deletions packages/vitnode/src/lib/motion.ts
Original file line number Diff line number Diff line change
@@ -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 };
18 changes: 13 additions & 5 deletions packages/vitnode/src/views/auth/sign-in/form/form.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<div className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>{t(`errors.${error}.title`)}</AlertTitle>
<AlertDescription>{t(`errors.${error}.desc`)}</AlertDescription>
</Alert>
<motion.div
animate={shouldReduceMotion ? undefined : SHAKE_KEYFRAMES}
transition={SHAKE_TRANSITION}
>
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>{t(`errors.${error}.title`)}</AlertTitle>
<AlertDescription>{t(`errors.${error}.desc`)}</AlertDescription>
</Alert>
</motion.div>
)}
Comment on lines +23 to 39

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of the shake animation will only trigger the first time an error is displayed. If the user submits the form again and the same error occurs, the animation will not replay because the animate prop on motion.div remains a constant. This is inconsistent with the field-level error animations, which shake on every invalid submission.

To ensure the animation plays on every error, I recommend using the useAnimate hook, similar to how it's used for AutoFormField. This gives you imperative control to trigger the animation.

You'll also need to adjust your imports:

import { useAnimate, useReducedMotion } from 'motion/react';
import { useEffect } from 'react';

Note: For this useEffect to re-trigger on consecutive identical errors, the error state from useFormSignIn should be a new object/value for each submission failure. For example, instead of just a string, it could be an object like { message: 'the_error', id: Date.now() }.

  const shouldReduceMotion = useReducedMotion();
  const { onSubmit, error, formSchema } = useFormSignIn({ isAdmin });
  const [scope, animate] = useAnimate<HTMLDivElement>();

  useEffect(() => {
    if (error && !shouldReduceMotion && scope.current) {
      animate(scope.current, SHAKE_KEYFRAMES, SHAKE_TRANSITION);
    }
  }, [error, animate, scope, shouldReduceMotion]);

  return (
    <div className="space-y-4">
      {error && (
        <div ref={scope}>
          <Alert variant="destructive">
            <AlertCircle className="size-4" />
            <AlertTitle>{t(`errors.${error}.title`)}</AlertTitle>
            <AlertDescription>{t(`errors.${error}.desc`)}</AlertDescription>
          </Alert>
        </div>
      )}


<AutoForm
Expand Down
Loading