diff --git a/apps/docs/src/remix-hook-form/autofill-controlled-inputs.stories.tsx b/apps/docs/src/remix-hook-form/autofill-controlled-inputs.stories.tsx new file mode 100644 index 00000000..f3fd6305 --- /dev/null +++ b/apps/docs/src/remix-hook-form/autofill-controlled-inputs.stories.tsx @@ -0,0 +1,171 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@lambdacurry/forms/ui/button'; +import { useAutofillControlledInput } from '@lambdacurry/forms/ui/hooks/use-autofill-controlled-input'; +import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from '@lambdacurry/forms/ui/form'; +import { TextInput } from '@lambdacurry/forms/ui/text-input'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { type ActionFunctionArgs, useFetcher } from 'react-router'; +import { useController } from 'react-hook-form'; +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 useAutofillControlledInput hook +const ControlledTextField = ({ + name, + label, + description, + type = 'text', + autoComplete, +}: { + name: keyof FormData; + label: string; + description?: string; + type?: string; + autoComplete?: string; +}) => { + const controller = useController({ name }); + const { isAutofilled, fieldProps } = useAutofillControlledInput(controller); + + return ( +
+ + {label} + + + + {description && {description}} + {controller.fieldState.error && ( + {controller.fieldState.error.message} + )} + + {isAutofilled && ( +
+ Autofilled +
+ )} +
+ ); +}; + +const AutofillControlledInputsExample = () => { + 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 Controlled Inputs

+

+ This form demonstrates autofill detection using controlled inputs with useController. + 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/AutofillControlledInputs', + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Demonstrates autofill detection using controlled inputs with useController.' + } + } + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const ControlledInputsExample: Story = { + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: AutofillControlledInputsExample, + 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..247c6151 --- /dev/null +++ b/packages/components/src/ui/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-autofill-controlled-input'; + diff --git a/packages/components/src/ui/hooks/use-autofill-controlled-input.ts b/packages/components/src/ui/hooks/use-autofill-controlled-input.ts new file mode 100644 index 00000000..7c6dd59e --- /dev/null +++ b/packages/components/src/ui/hooks/use-autofill-controlled-input.ts @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react'; +import { type FieldValues, type UseControllerReturn } from 'react-hook-form'; + +/** + * Hook to detect browser autofill using controlled inputs with useController. + * This technique works by monitoring the controlled input's value changes + * and detecting when they happen without user interaction. + * + * @param controller - The controller returned from useController + * @returns Object containing isAutofilled state and reset function + */ +export function useAutofillControlledInput( + controller: UseControllerReturn +) { + const [isAutofilled, setIsAutofilled] = useState(false); + const [userInteracted, setUserInteracted] = useState(false); + + // Monitor value changes from the controller + useEffect(() => { + const { field } = controller; + + // If the field has a value but the user hasn't interacted with it yet, + // it might be autofilled + if (field.value && !userInteracted) { + setIsAutofilled(true); + } + }, [controller.field.value, userInteracted]); + + // Create handlers to track user interaction + const handleUserInteraction = () => { + setUserInteracted(true); + + // If the user interacts with the field, it's no longer considered autofilled + if (isAutofilled) { + setIsAutofilled(false); + } + }; + + // Enhanced onChange handler that tracks user interaction + const onChange = (e: React.ChangeEvent) => { + handleUserInteraction(); + controller.field.onChange(e); + }; + + // Enhanced onBlur handler that tracks user interaction + const onBlur = (e: React.FocusEvent) => { + handleUserInteraction(); + controller.field.onBlur(); + }; + + // Function to reset the autofilled state + const resetAutofilled = () => { + setIsAutofilled(false); + setUserInteracted(true); + }; + + return { + isAutofilled, + resetAutofilled, + fieldProps: { + ...controller.field, + onChange, + onBlur, + onFocus: handleUserInteraction, + onKeyDown: handleUserInteraction, + onPaste: handleUserInteraction, + }, + }; +} +