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
146 changes: 146 additions & 0 deletions apps/docs/src/remix-hook-form/autofill-value-comparison.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof formSchema>;

// Custom TextField that uses the useAutofillDetection hook
const AutofillDetectingTextField = (props: React.ComponentProps<typeof TextField>) => {
const inputRef = useRef<HTMLInputElement>(null);
const { isAutofilled } = useAutofillDetection(inputRef, props.value as string || '');

return (
<div className="relative">
<TextField {...props} ref={inputRef} />
{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 AutofillValueComparisonExample = () => {
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 Value Comparison</h2>
<p className="text-sm text-gray-500 mb-6">
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.
</p>

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

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

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

<AutofillDetectingTextField
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<typeof TextField> = {
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<typeof meta>;

export const ValueComparisonExample: Story = {
decorators: [
withReactRouterStubDecorator({
routes: [
{
path: '/',
Component: AutofillValueComparisonExample,
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-detection';

69 changes: 69 additions & 0 deletions packages/components/src/ui/hooks/use-autofill-detection.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement | HTMLTextAreaElement>,
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 };
}