Skip to content
Closed
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
171 changes: 171 additions & 0 deletions apps/docs/src/remix-hook-form/autofill-controlled-inputs.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof formSchema>;

// 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 (
<div className="relative">
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<TextInput
{...fieldProps}
type={type}
autoComplete={autoComplete}
className={isAutofilled ? 'border-green-500 bg-green-50' : ''}
/>
</FormControl>
{description && <FormDescription>{description}</FormDescription>}
{controller.fieldState.error && (
<FormMessage>{controller.fieldState.error.message}</FormMessage>
)}
</FormItem>
{isAutofilled && (
<div className="absolute right-0 top-0 bg-green-100 text-green-800 text-xs px-2 py-1 rounded">
Autofilled
</div>
)}
</div>
);
};

const AutofillControlledInputsExample = () => {
const fetcher = useFetcher<{ message: string }>();
const methods = useRemixForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
phone: '',
address: '',
},
fetcher,
submitConfig: {
action: '/',
method: 'post',
},
});

return (
<RemixFormProvider {...methods}>
<fetcher.Form onSubmit={methods.handleSubmit}>
<div className="space-y-4 max-w-md mx-auto">
<h2 className="text-xl font-bold mb-4">Autofill Detection with Controlled Inputs</h2>
<p className="text-sm text-gray-500 mb-6">
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.
</p>

<div className="space-y-4">
<ControlledTextField
name="name"
label="Full Name"
autoComplete="name"
/>

<ControlledTextField
name="email"
label="Email Address"
type="email"
autoComplete="email"
/>

<ControlledTextField
name="phone"
label="Phone Number"
type="tel"
autoComplete="tel"
/>

<ControlledTextField
name="address"
label="Street Address"
autoComplete="street-address"
/>

<Button type="submit" className="w-full mt-6">
Submit
</Button>

{fetcher.data?.message && (
<p className="mt-2 text-green-600">{fetcher.data.message}</p>
)}
</div>
</div>
</fetcher.Form>
</RemixFormProvider>
);
};

const handleFormSubmission = async (request: Request) => {
const { data, errors } = await getValidatedFormData<FormData>(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<typeof meta>;

export const ControlledInputsExample: Story = {
decorators: [
withReactRouterStubDecorator({
routes: [
{
path: '/',
Component: AutofillControlledInputsExample,
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
},
],
}),
],
};
2 changes: 2 additions & 0 deletions packages/components/src/ui/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './use-autofill-controlled-input';

70 changes: 70 additions & 0 deletions packages/components/src/ui/hooks/use-autofill-controlled-input.ts
Original file line number Diff line number Diff line change
@@ -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<TFieldValues extends FieldValues = FieldValues>(
controller: UseControllerReturn<TFieldValues>
) {
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<HTMLInputElement>) => {
handleUserInteraction();
controller.field.onChange(e);
};

// Enhanced onBlur handler that tracks user interaction
const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
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,
},
};
}