diff --git a/apps/docs/src/remix-hook-form/autofill-value-comparison.stories.tsx b/apps/docs/src/remix-hook-form/autofill-value-comparison.stories.tsx new file mode 100644 index 00000000..7bc0d3f2 --- /dev/null +++ b/apps/docs/src/remix-hook-form/autofill-value-comparison.stories.tsx @@ -0,0 +1,146 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { TextField } from '@lambdacurry/forms/remix-hook-form/text-field'; +import { Button } from '@lambdacurry/forms/ui/button'; +import { useAutofillDetection } from '@lambdacurry/forms/ui/hooks/use-autofill-detection'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useRef } from 'react'; +import { type ActionFunctionArgs, useFetcher } from 'react-router'; +import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; +import { z } from 'zod'; +import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; + +const formSchema = z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Invalid email address'), + phone: z.string().min(1, 'Phone number is required'), + address: z.string().min(1, 'Address is required'), +}); + +type FormData = z.infer; + +// Custom TextField that uses the useAutofillDetection hook +const AutofillDetectingTextField = (props: React.ComponentProps) => { + const inputRef = useRef(null); + const { isAutofilled } = useAutofillDetection(inputRef, props.value as string || ''); + + return ( +
+ + {isAutofilled && ( +
+ Autofilled +
+ )} +
+ ); +}; + +const AutofillValueComparisonExample = () => { + const fetcher = useFetcher<{ message: string }>(); + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + email: '', + phone: '', + address: '', + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + return ( + + +
+

Autofill Detection with Value Comparison

+

+ This form demonstrates autofill detection using value comparison with useRef. Try using your browser's + autofill feature to populate these fields and watch for the "Autofilled" indicator. +

+ +
+ + + + + + + + + + + {fetcher.data?.message && ( +

{fetcher.data.message}

+ )} +
+
+
+
+ ); +}; + +const handleFormSubmission = async (request: Request) => { + const { data, errors } = await getValidatedFormData(request, zodResolver(formSchema)); + + if (errors) { + return { errors }; + } + + return { message: 'Form submitted successfully' }; +}; + +const meta: Meta = { + title: 'RemixHookForm/AutofillValueComparison', + component: TextField, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Demonstrates autofill detection using value comparison with useRef.' + } + } + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const ValueComparisonExample: Story = { + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: AutofillValueComparisonExample, + action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), + }, + ], + }), + ], +}; + diff --git a/packages/components/src/ui/hooks/index.ts b/packages/components/src/ui/hooks/index.ts new file mode 100644 index 00000000..308aa15f --- /dev/null +++ b/packages/components/src/ui/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-autofill-detection'; + diff --git a/packages/components/src/ui/hooks/use-autofill-detection.ts b/packages/components/src/ui/hooks/use-autofill-detection.ts new file mode 100644 index 00000000..efe47edc --- /dev/null +++ b/packages/components/src/ui/hooks/use-autofill-detection.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Hook to detect browser autofill by comparing the current value with the previous value + * and checking if the change was triggered by a user event. + * + * @param inputRef - Reference to the input element + * @param value - Current value of the input + * @returns Object containing isAutofilled state and reset function + */ +export function useAutofillDetection( + inputRef: React.RefObject, + value: string +) { + const [isAutofilled, setIsAutofilled] = useState(false); + const previousValueRef = useRef(value); + const userInteractionRef = useRef(false); + + // Track user interactions + useEffect(() => { + const element = inputRef.current; + if (!element) return; + + const handleUserInteraction = () => { + userInteractionRef.current = true; + // Reset after a short delay to catch only immediate changes + setTimeout(() => { + userInteractionRef.current = false; + }, 50); + }; + + // Track events that indicate user interaction + element.addEventListener('keydown', handleUserInteraction); + element.addEventListener('input', handleUserInteraction); + element.addEventListener('paste', handleUserInteraction); + element.addEventListener('cut', handleUserInteraction); + element.addEventListener('mouseup', handleUserInteraction); // For context menu paste + + return () => { + element.removeEventListener('keydown', handleUserInteraction); + element.removeEventListener('input', handleUserInteraction); + element.removeEventListener('paste', handleUserInteraction); + element.removeEventListener('cut', handleUserInteraction); + element.removeEventListener('mouseup', handleUserInteraction); + }; + }, [inputRef]); + + // Detect value changes that weren't triggered by user interaction + useEffect(() => { + // Skip initial render + if (previousValueRef.current === value) return; + + // If value changed but no user interaction was detected, it might be autofill + if (value && value !== previousValueRef.current && !userInteractionRef.current) { + setIsAutofilled(true); + } + + // Update previous value reference + previousValueRef.current = value; + }, [value]); + + // Function to reset the autofilled state + const resetAutofilled = () => { + setIsAutofilled(false); + }; + + return { isAutofilled, resetAutofilled }; +} +