Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/phone-input-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lambdacurry/forms": patch
---

Fix phone input cursor positioning and text selection.
337 changes: 337 additions & 0 deletions apps/docs/src/remix-hook-form/phone-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { z } from 'zod';
import type { ElementType } from 'react';
import type { FetcherWithComponents } from 'react-router';
import type { FormMessageProps } from '@lambdacurry/forms/ui/form';
import { useRef, useEffect } from 'react';

// Mock useFetcher
jest.mock('react-router', () => ({
Expand Down Expand Up @@ -186,4 +187,340 @@ describe('PhoneInput Component', () => {
expect(screen.getByLabelText('International Phone Number')).toBeInTheDocument();
});
});

describe('MI-1188: Selection Replacement and Cursor Positioning', () => {
it('replaces selected middle digits when typing (AC #1)', async () => {
const user = userEvent.setup();
render(<TestPhoneInputForm />);

const input = screen.getByLabelText('USA Phone Number') as HTMLInputElement;

// Type initial number
await user.type(input, '2025550123');
await waitFor(() => {
expect(input.value).toBe('(202) 555-0123');
});

// Select middle digits "555" (positions 6-8 in formatted string "(202) 555-0123")
input.focus();
input.setSelectionRange(6, 9);

// Type replacement digits
await user.keyboard('999');

// Should replace selected "555" with "999"
await waitFor(() => {
expect(input.value).toBe('(202) 999-0123');
});
});

it('replaces entire number when all text selected (AC #2)', async () => {
const user = userEvent.setup();
render(<TestPhoneInputForm />);

const input = screen.getByLabelText('USA Phone Number') as HTMLInputElement;

// Type initial number
await user.type(input, '2025550123');
await waitFor(() => {
expect(input.value).toBe('(202) 555-0123');
});

// Select all text
input.focus();
input.setSelectionRange(0, input.value.length);

// Type new number
await user.keyboard('310');

// Should replace entire value
await waitFor(() => {
expect(input.value).toBe('(310');
});
});

it('maintains cursor position after formatting during typing', async () => {
const user = userEvent.setup();
render(<TestPhoneInputForm />);

const input = screen.getByLabelText('USA Phone Number') as HTMLInputElement;

// Type partial number
await user.type(input, '202555');

await waitFor(() => {
expect(input.value).toBe('(202) 555');
// Cursor should be at exact position after last digit (after "555")
expect(input.selectionStart).toBe(9); // "(202) 555" length
});
});

it('handles backspace correctly with cursor positioning', async () => {
const user = userEvent.setup();
render(<TestPhoneInputForm />);

const input = screen.getByLabelText('USA Phone Number') as HTMLInputElement;

// Type full number
await user.type(input, '2025550123');
await waitFor(() => {
expect(input.value).toBe('(202) 555-0123');
});

// Backspace once
await user.keyboard('{Backspace}');

await waitFor(() => {
expect(input.value).toBe('(202) 555-012');
});
});
});

describe('Paste Events', () => {
it('formats pasted plain digits correctly', async () => {
const user = userEvent.setup();
render(<TestPhoneInputForm />);

const input = screen.getByLabelText('USA Phone Number');

await user.click(input);
await user.paste('2025550123');

await waitFor(() => {
expect((input as HTMLInputElement).value).toBe('(202) 555-0123');
});
});

it('handles pasted formatted number', async () => {
const user = userEvent.setup();
render(<TestPhoneInputForm />);

const input = screen.getByLabelText('USA Phone Number');

await user.click(input);
await user.paste('(202) 555-0123');

await waitFor(() => {
expect((input as HTMLInputElement).value).toBe('(202) 555-0123');
});
});

it('handles paste with selection replacement', async () => {
const user = userEvent.setup();
render(<TestPhoneInputForm />);

const input = screen.getByLabelText('USA Phone Number') as HTMLInputElement;

// Type initial number
await user.type(input, '2025550123');
await waitFor(() => {
expect(input.value).toBe('(202) 555-0123');
});

// Select middle portion
input.focus();
input.setSelectionRange(6, 9);

// Paste replacement
await user.paste('999');

await waitFor(() => {
expect(input.value).toBe('(202) 999-0123');
});
});
});

describe('Ref Forwarding (React 19 Compliance)', () => {
it('forwards ref correctly to input element', () => {
const TestRefComponent = () => {
const ref = useRef<HTMLInputElement>(null);
const mockFetcher = {
data: {},
state: 'idle' as const,
submit: jest.fn(),
Form: 'form' as ElementType,
} as unknown as FetcherWithComponents<unknown>;

mockUseFetcher.mockReturnValue(mockFetcher);

const methods = useRemixForm<TestFormData>({
resolver: zodResolver(testSchema),
defaultValues: { usaPhone: '', internationalPhone: '' },
fetcher: mockFetcher,
submitConfig: { action: '/test', method: 'post' },
});

return (
<RemixFormProvider {...methods}>
<form>
<PhoneInput name="usaPhone" label="Phone" ref={ref} />
<div data-testid="ref-check">{ref.current ? 'ref-attached' : 'no-ref'}</div>
</form>
</RemixFormProvider>
);
};

const { rerender } = render(<TestRefComponent />);

// Trigger rerender to ensure ref is attached
rerender(<TestRefComponent />);

const refCheck = screen.getByTestId('ref-check');
expect(refCheck.textContent).toBe('ref-attached');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('calls callback ref cleanup on unmount', () => {
const cleanup = jest.fn();
const callbackRef = jest.fn(() => cleanup);

const TestRefComponent = () => {
const mockFetcher = {
data: {},
state: 'idle' as const,
submit: jest.fn(),
Form: 'form' as ElementType,
} as unknown as FetcherWithComponents<unknown>;

mockUseFetcher.mockReturnValue(mockFetcher);

const methods = useRemixForm<TestFormData>({
resolver: zodResolver(testSchema),
defaultValues: { usaPhone: '', internationalPhone: '' },
fetcher: mockFetcher,
submitConfig: { action: '/test', method: 'post' },
});

return (
<RemixFormProvider {...methods}>
<form>
<PhoneInput name="usaPhone" label="Phone" ref={callbackRef} />
</form>
</RemixFormProvider>
);
};

const { unmount } = render(<TestRefComponent />);

expect(callbackRef).toHaveBeenCalledWith(expect.any(HTMLInputElement));
expect(callbackRef).toHaveBeenCalledTimes(1);

unmount();

expect(cleanup).toHaveBeenCalled();
// React 19: cleanup-returning callback should NOT be called with null
expect(callbackRef).toHaveBeenCalledTimes(1); // Still only 1 call
expect(callbackRef).not.toHaveBeenCalledWith(null);
});
});

describe('Focus/Blur Value Synchronization', () => {
it('defers external value updates while focused', async () => {
const TestValueSyncComponent = ({ value }: { value: string }) => {
const mockFetcher = {
data: {},
state: 'idle' as const,
submit: jest.fn(),
Form: 'form' as ElementType,
} as unknown as FetcherWithComponents<unknown>;

mockUseFetcher.mockReturnValue(mockFetcher);

const methods = useRemixForm<TestFormData>({
resolver: zodResolver(testSchema),
defaultValues: { usaPhone: value, internationalPhone: '' },
fetcher: mockFetcher,
submitConfig: { action: '/test', method: 'post' },
});

useEffect(() => {
methods.setValue('usaPhone', value);
}, [value, methods]);

return (
<RemixFormProvider {...methods}>
<form>
<PhoneInput name="usaPhone" label="Phone" />
</form>
</RemixFormProvider>
);
};

const user = userEvent.setup();
const { rerender } = render(<TestValueSyncComponent value="2025550100" />);

const input = screen.getByLabelText('Phone') as HTMLInputElement;

await waitFor(() => {
expect(input.value).toBe('(202) 555-0100');
});

// Focus the input
await user.click(input);
expect(input).toHaveFocus();

// External value change while focused
rerender(<TestValueSyncComponent value="3105550200" />);

// Should still show old value while focused
await waitFor(() => {
expect(input.value).toBe('(202) 555-0100');
});

// Blur the input
await user.tab();

// Should now show new value after blur
await waitFor(() => {
expect(input.value).toBe('(310) 555-0200');
});
});

it('applies external value updates immediately when not focused', async () => {
const TestValueSyncComponent = ({ value }: { value: string }) => {
const mockFetcher = {
data: {},
state: 'idle' as const,
submit: jest.fn(),
Form: 'form' as ElementType,
} as unknown as FetcherWithComponents<unknown>;

mockUseFetcher.mockReturnValue(mockFetcher);

const methods = useRemixForm<TestFormData>({
resolver: zodResolver(testSchema),
defaultValues: { usaPhone: value, internationalPhone: '' },
fetcher: mockFetcher,
submitConfig: { action: '/test', method: 'post' },
});

useEffect(() => {
methods.setValue('usaPhone', value);
}, [value, methods]);

return (
<RemixFormProvider {...methods}>
<form>
<PhoneInput name="usaPhone" label="Phone" />
</form>
</RemixFormProvider>
);
};

const { rerender } = render(<TestValueSyncComponent value="2025550100" />);

const input = screen.getByLabelText('Phone') as HTMLInputElement;

await waitFor(() => {
expect(input.value).toBe('(202) 555-0100');
});

// External value change while NOT focused
rerender(<TestValueSyncComponent value="3105550200" />);

// Should apply new value immediately
await waitFor(() => {
expect(input.value).toBe('(310) 555-0200');
});
});
});
});
6 changes: 5 additions & 1 deletion packages/components/src/ui/checkbox-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ const CheckboxField = ({

return (
<FormItem
className={cn('flex flex-row gap-3 space-y-0', hasSupportingText ? 'items-start' : 'items-center', className)}
className={cn(
'flex flex-row gap-3 space-y-0',
hasSupportingText ? 'items-start' : 'items-center',
className,
)}
>
<FormControl Component={components?.FormControl}>
<CheckboxComponent
Expand Down
Loading