diff --git a/apps/docs/src/remix-hook-form/autofill-form-state.stories.tsx b/apps/docs/src/remix-hook-form/autofill-form-state.stories.tsx new file mode 100644 index 00000000..3fe40e39 --- /dev/null +++ b/apps/docs/src/remix-hook-form/autofill-form-state.stories.tsx @@ -0,0 +1,162 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { TextField } from '@lambdacurry/forms/remix-hook-form/text-field'; +import { Button } from '@lambdacurry/forms/ui/button'; +import { useAutofillFormState } from '@lambdacurry/forms/ui/hooks/use-autofill-form-state'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +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; + +const AutofillFormStateExample = () => { + const fetcher = useFetcher<{ message: string }>(); + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + email: '', + phone: '', + address: '', + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + // Use the autofill detection hook for each field + const nameAutofill = useAutofillFormState(methods, 'name'); + const emailAutofill = useAutofillFormState(methods, 'email'); + const phoneAutofill = useAutofillFormState(methods, 'phone'); + const addressAutofill = useAutofillFormState(methods, 'address'); + + return ( + + +
+

Autofill Detection with Form State Monitoring

+

+ This form demonstrates autofill detection by monitoring form state changes. + Try using your browser's autofill feature to populate these fields and watch for the "Autofilled" indicator. +

+ +
+
+ + {nameAutofill.isAutofilled && ( +
+ Autofilled +
+ )} +
+ +
+ + {emailAutofill.isAutofilled && ( +
+ Autofilled +
+ )} +
+ +
+ + {phoneAutofill.isAutofilled && ( +
+ Autofilled +
+ )} +
+ +
+ + {addressAutofill.isAutofilled && ( +
+ Autofilled +
+ )} +
+ + + + {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/AutofillFormState', + component: TextField, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Demonstrates autofill detection by monitoring form state changes.' + } + } + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const FormStateExample: Story = { + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: AutofillFormStateExample, + 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..82065533 --- /dev/null +++ b/packages/components/src/ui/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-autofill-form-state'; + diff --git a/packages/components/src/ui/hooks/use-autofill-form-state.ts b/packages/components/src/ui/hooks/use-autofill-form-state.ts new file mode 100644 index 00000000..131e9b42 --- /dev/null +++ b/packages/components/src/ui/hooks/use-autofill-form-state.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from 'react'; +import { type FieldValues, type UseFormReturn, type FieldPath } from 'react-hook-form'; + +/** + * Hook to detect browser autofill by monitoring form state changes. + * This technique works by watching for changes in the form state that + * weren't triggered by user interaction. + * + * @param form - The form instance from useForm or useRemixForm + * @param name - The field name to monitor + * @returns Object containing isAutofilled state and reset function + */ +export function useAutofillFormState< + TFieldValues extends FieldValues = FieldValues, + TContext = any +>( + form: UseFormReturn, + name: FieldPath +) { + const [isAutofilled, setIsAutofilled] = useState(false); + const userInteractionRef = useRef(false); + const previousValueRef = useRef(form.getValues(name)); + const touchedRef = useRef(false); + + // Subscribe to form state changes + useEffect(() => { + const subscription = form.watch((values, { name: changedField, type }) => { + // Only process changes for the field we're monitoring + if (changedField !== name) return; + + const currentValue = values[name as keyof typeof values]; + + // Skip if value hasn't changed + if (currentValue === previousValueRef.current) return; + + // If the field was changed programmatically (not by user interaction) + // and the value is not empty, it might be autofill + if ( + !userInteractionRef.current && + currentValue && + type !== 'change' && + type !== 'blur' && + !touchedRef.current + ) { + setIsAutofilled(true); + } + + // Update previous value + previousValueRef.current = currentValue; + }); + + return () => subscription.unsubscribe(); + }, [form, name]); + + // Track user interactions with the form + useEffect(() => { + const handleUserInteraction = () => { + userInteractionRef.current = true; + touchedRef.current = true; + + // Reset after a short delay to catch only immediate changes + setTimeout(() => { + userInteractionRef.current = false; + }, 50); + }; + + // Track events that indicate user interaction + document.addEventListener('keydown', handleUserInteraction); + document.addEventListener('input', handleUserInteraction); + document.addEventListener('paste', handleUserInteraction); + document.addEventListener('cut', handleUserInteraction); + document.addEventListener('mouseup', handleUserInteraction); + + return () => { + document.removeEventListener('keydown', handleUserInteraction); + document.removeEventListener('input', handleUserInteraction); + document.removeEventListener('paste', handleUserInteraction); + document.removeEventListener('cut', handleUserInteraction); + document.removeEventListener('mouseup', handleUserInteraction); + }; + }, []); + + // Function to reset the autofilled state + const resetAutofilled = () => { + setIsAutofilled(false); + touchedRef.current = true; + }; + + return { isAutofilled, resetAutofilled }; +} +