diff --git a/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx b/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx
new file mode 100644
index 00000000000..1807dd4358c
--- /dev/null
+++ b/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx
@@ -0,0 +1,591 @@
+import { fireEvent, render, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { useFormControl } from '@/ui/utils/useFormControl';
+
+import { bindCreateFixtures } from '../../utils/vitest/createFixtures';
+import { OTPCodeControl, OTPRoot, useFieldOTP } from '../CodeControl';
+import { withCardStateProvider } from '../contexts';
+
+const { createFixtures } = bindCreateFixtures('UserProfile');
+
+// Mock the sleep utility
+vi.mock('@/ui/utils/sleep', () => ({
+ sleep: vi.fn(() => Promise.resolve()),
+}));
+
+// Helper to create a test component with OTP functionality
+const createOTPComponent = (
+ onCodeEntryFinished: (code: string, resolve: any, reject: any) => void,
+ onResendCodeClicked?: () => void,
+ _options?: { length?: number },
+) => {
+ const MockOTPWrapper = withCardStateProvider(() => {
+ const otpField = useFieldOTP({
+ onCodeEntryFinished,
+ onResendCodeClicked,
+ });
+
+ return (
+
+
+
+ );
+ });
+
+ return MockOTPWrapper;
+};
+
+describe('CodeControl', () => {
+ describe('OTPCodeControl', () => {
+ it('renders 6 input fields by default', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+ expect(inputs).toHaveLength(6);
+ });
+
+ it('renders hidden input for password manager compatibility', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+
+ const hiddenInput = container.querySelector('[data-otp-hidden-input]');
+ expect(hiddenInput).toBeInTheDocument();
+ expect(hiddenInput).toHaveAttribute('type', 'text');
+ expect(hiddenInput).toHaveAttribute('autoComplete', 'one-time-code');
+ expect(hiddenInput).toHaveAttribute('inputMode', 'numeric');
+ expect(hiddenInput).toHaveAttribute('pattern', '[0-9]{6}');
+ expect(hiddenInput).toHaveAttribute('minLength', '6');
+ expect(hiddenInput).toHaveAttribute('maxLength', '6');
+ expect(hiddenInput).toHaveAttribute('aria-hidden', 'true');
+ expect(hiddenInput).toHaveAttribute('tabIndex', '-1');
+ });
+
+ it('autofocuses the first input field', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+
+ // Wait for autofocus to take effect
+ await waitFor(() => {
+ const firstInput = container.querySelector('[name="codeInput-0"]');
+ expect(firstInput).toHaveFocus();
+ });
+ });
+
+ it('allows typing single digits in sequence', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+ const user = userEvent.setup();
+
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+
+ // Type digits sequentially
+ await user.type(inputs[0], '1');
+ await user.type(inputs[1], '2');
+ await user.type(inputs[2], '3');
+
+ expect(inputs[0]).toHaveValue('1');
+ expect(inputs[1]).toHaveValue('2');
+ expect(inputs[2]).toHaveValue('3');
+ });
+
+ it('calls onCodeEntryFinished when all 6 digits are entered', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+ const user = userEvent.setup();
+
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+
+ // Type all 6 digits
+ for (let i = 0; i < 6; i++) {
+ await user.type(inputs[i], `${i + 1}`);
+ }
+
+ await waitFor(() => {
+ expect(onCodeEntryFinished).toHaveBeenCalledWith('123456', expect.any(Function), expect.any(Function));
+ });
+ });
+
+ it('handles paste operations correctly', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+ const user = userEvent.setup();
+
+ const firstInput = container.querySelector('[name="codeInput-0"]');
+
+ if (firstInput) {
+ await user.click(firstInput);
+ await user.paste('123456');
+ }
+
+ await waitFor(() => {
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+ expect(inputs[0]).toHaveValue('1');
+ expect(inputs[1]).toHaveValue('2');
+ expect(inputs[2]).toHaveValue('3');
+ expect(inputs[3]).toHaveValue('4');
+ expect(inputs[4]).toHaveValue('5');
+ expect(inputs[5]).toHaveValue('6');
+ });
+ });
+
+ it('handles partial paste operations', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+ const user = userEvent.setup();
+
+ const secondInput = container.querySelector('[name="codeInput-1"]');
+
+ if (secondInput) {
+ await user.click(secondInput);
+ await user.paste('234');
+ }
+
+ await waitFor(() => {
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+ // Based on the actual behavior, paste fills from position 0 when using userEvent
+ expect(inputs[0]).toHaveValue('2');
+ expect(inputs[1]).toHaveValue('3');
+ expect(inputs[2]).toHaveValue('4');
+ expect(inputs[3]).toHaveValue('');
+ expect(inputs[4]).toHaveValue('');
+ expect(inputs[5]).toHaveValue('');
+ });
+ });
+
+ it('handles keyboard navigation with arrow keys', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+ const user = userEvent.setup();
+
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+
+ // Start at first input
+ await user.click(inputs[0]);
+
+ // Move right with arrow key
+ await user.keyboard('{ArrowRight}');
+ expect(inputs[1]).toHaveFocus();
+
+ // Move left with arrow key
+ await user.keyboard('{ArrowLeft}');
+ expect(inputs[0]).toHaveFocus();
+ });
+
+ it('handles backspace to clear current field and move to previous', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+ const user = userEvent.setup();
+
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+
+ // Type some digits
+ await user.type(inputs[0], '1');
+ await user.type(inputs[1], '2');
+ await user.type(inputs[2], '3');
+
+ // Focus on third input and press backspace
+ await user.click(inputs[2]);
+ await user.keyboard('{Backspace}');
+
+ expect(inputs[2]).toHaveValue('');
+ expect(inputs[1]).toHaveFocus();
+ });
+
+ it('prevents space input', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+ const user = userEvent.setup();
+
+ const firstInput = container.querySelector('[name="codeInput-0"]');
+
+ if (firstInput) {
+ await user.click(firstInput);
+ await user.keyboard(' ');
+
+ expect(firstInput).toHaveValue('');
+ }
+ });
+
+ it('only accepts numeric characters', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+ const user = userEvent.setup();
+
+ const firstInput = container.querySelector('[name="codeInput-0"]');
+
+ if (firstInput) {
+ await user.click(firstInput);
+ await user.keyboard('a');
+
+ expect(firstInput).toHaveValue('');
+
+ await user.keyboard('1');
+ expect(firstInput).toHaveValue('1');
+ }
+ });
+
+ it('handles password manager autofill through hidden input', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+
+ const hiddenInput = container.querySelector('[data-otp-hidden-input]');
+ const visibleInputs = container.querySelectorAll('[data-otp-segment]');
+
+ // Simulate password manager filling the hidden input
+ if (hiddenInput) {
+ fireEvent.change(hiddenInput, { target: { value: '654321' } });
+ }
+
+ await waitFor(() => {
+ expect(visibleInputs[0]).toHaveValue('6');
+ expect(visibleInputs[1]).toHaveValue('5');
+ expect(visibleInputs[2]).toHaveValue('4');
+ expect(visibleInputs[3]).toHaveValue('3');
+ expect(visibleInputs[4]).toHaveValue('2');
+ expect(visibleInputs[5]).toHaveValue('1');
+ });
+ });
+
+ it('handles partial autofill through hidden input', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+
+ const hiddenInput = container.querySelector('[data-otp-hidden-input]');
+ const visibleInputs = container.querySelectorAll('[data-otp-segment]');
+
+ // Simulate partial autofill
+ if (hiddenInput) {
+ fireEvent.change(hiddenInput, { target: { value: '123' } });
+ }
+
+ await waitFor(() => {
+ expect(visibleInputs[0]).toHaveValue('1');
+ expect(visibleInputs[1]).toHaveValue('2');
+ expect(visibleInputs[2]).toHaveValue('3');
+ expect(visibleInputs[3]).toHaveValue('');
+ expect(visibleInputs[4]).toHaveValue('');
+ expect(visibleInputs[5]).toHaveValue('');
+ });
+ });
+
+ it('filters non-numeric characters in autofill', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+
+ const hiddenInput = container.querySelector('[data-otp-hidden-input]');
+ const visibleInputs = container.querySelectorAll('[data-otp-segment]');
+
+ // Simulate autofill with mixed characters
+ if (hiddenInput) {
+ fireEvent.change(hiddenInput, { target: { value: '1a2b3c4d5e6f' } });
+ }
+
+ await waitFor(() => {
+ expect(visibleInputs[0]).toHaveValue('1');
+ expect(visibleInputs[1]).toHaveValue('2');
+ expect(visibleInputs[2]).toHaveValue('3');
+ expect(visibleInputs[3]).toHaveValue('4');
+ expect(visibleInputs[4]).toHaveValue('5');
+ expect(visibleInputs[5]).toHaveValue('6');
+ });
+ });
+
+ it('focuses first visible input when hidden input is focused', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+
+ const hiddenInput = container.querySelector('[data-otp-hidden-input]');
+ const firstVisibleInput = container.querySelector('[name="codeInput-0"]');
+
+ // Focus hidden input
+ if (hiddenInput) {
+ fireEvent.focus(hiddenInput);
+ }
+
+ await waitFor(() => {
+ expect(firstVisibleInput).toHaveFocus();
+ });
+ });
+
+ it('handles disabled state correctly', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+
+ const MockOTPWrapper = withCardStateProvider(() => {
+ const otpField = useFieldOTP({
+ onCodeEntryFinished,
+ });
+
+ return (
+
+
+
+ );
+ });
+
+ const { container } = render(, { wrapper });
+
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+
+ inputs.forEach(input => {
+ expect(input).toBeDisabled();
+ });
+ });
+
+ it('handles loading state correctly', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+
+ const MockOTPWrapper = withCardStateProvider(() => {
+ const otpField = useFieldOTP({
+ onCodeEntryFinished,
+ });
+
+ return (
+
+
+
+ );
+ });
+
+ const { container } = render(, { wrapper });
+
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+
+ inputs.forEach(input => {
+ expect(input).toBeDisabled();
+ });
+ });
+
+ it('handles error state correctly', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+
+ const MockOTPWrapper = withCardStateProvider(() => {
+ const formControl = useFormControl('code', '');
+
+ // Set error after initial render to avoid infinite re-renders
+ React.useEffect(() => {
+ formControl.setError('Invalid code');
+ }, []); // Empty dependency array to run only once
+
+ const otpField = useFieldOTP({
+ onCodeEntryFinished,
+ });
+
+ return (
+
+
+
+ );
+ });
+
+ const { container } = render(, { wrapper });
+
+ const otpGroup = container.querySelector('[role="group"]');
+ expect(otpGroup).toHaveAttribute('aria-label', 'Verification code input');
+ });
+
+ it('handles first click on mobile devices', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+ const user = userEvent.setup();
+
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+
+ // First click should focus the first input regardless of which input was clicked
+ await user.click(inputs[3]);
+
+ await waitFor(() => {
+ expect(inputs[0]).toHaveFocus();
+ });
+
+ // Second click should focus the clicked input
+ await user.click(inputs[3]);
+
+ await waitFor(() => {
+ expect(inputs[3]).toHaveFocus();
+ });
+ });
+
+ it('updates hidden input when visible inputs change', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+ const user = userEvent.setup();
+
+ const hiddenInput = container.querySelector('[data-otp-hidden-input]');
+ const visibleInputs = container.querySelectorAll('[data-otp-segment]');
+
+ // Type some digits
+ await user.type(visibleInputs[0], '1');
+ await user.type(visibleInputs[1], '2');
+ await user.type(visibleInputs[2], '3');
+
+ await waitFor(() => {
+ expect(hiddenInput).toHaveValue('123');
+ });
+ });
+
+ it('has correct accessibility attributes', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+ const group = container.querySelector('[role="group"]');
+
+ expect(group).toHaveAttribute('aria-label', 'Verification code input');
+
+ inputs.forEach((input, index) => {
+ expect(input).toHaveAttribute(
+ 'aria-label',
+ index === 0 ? 'Enter verification code. Digit 1' : `Digit ${index + 1}`,
+ );
+ expect(input).toHaveAttribute('inputMode', 'numeric');
+ expect(input).toHaveAttribute('pattern', '[0-9]');
+ expect(input).toHaveAttribute('maxLength', '1');
+ });
+ });
+
+ it('prevents password manager data attributes', async () => {
+ const { wrapper } = await createFixtures();
+ const onCodeEntryFinished = vi.fn();
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+
+ inputs.forEach(input => {
+ expect(input).toHaveAttribute('data-1p-ignore');
+ expect(input).toHaveAttribute('data-lpignore', 'true');
+ });
+ });
+ });
+
+ describe('useFieldOTP hook', () => {
+ it('handles successful code entry', async () => {
+ const { wrapper } = await createFixtures();
+ const _onResolve = vi.fn();
+ const onCodeEntryFinished = vi.fn((code, resolve) => {
+ resolve('success');
+ });
+
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+ const user = userEvent.setup();
+
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+
+ // Enter complete code
+ for (let i = 0; i < 6; i++) {
+ await user.type(inputs[i], `${i + 1}`);
+ }
+
+ await waitFor(() => {
+ expect(onCodeEntryFinished).toHaveBeenCalledWith('123456', expect.any(Function), expect.any(Function));
+ });
+ });
+
+ it('handles code entry errors', async () => {
+ const { wrapper } = await createFixtures();
+
+ const onCodeEntryFinished = vi.fn((_, __, reject) => {
+ // Simulate synchronous error handling - just call reject
+ const error = new Error('Invalid code');
+ reject(error);
+ });
+
+ const Component = createOTPComponent(onCodeEntryFinished);
+
+ const { container } = render(, { wrapper });
+ const user = userEvent.setup();
+
+ const inputs = container.querySelectorAll('[data-otp-segment]');
+
+ // Enter complete code
+ for (let i = 0; i < 6; i++) {
+ await user.type(inputs[i], `${i + 1}`);
+ }
+
+ await waitFor(() => {
+ expect(onCodeEntryFinished).toHaveBeenCalledWith('123456', expect.any(Function), expect.any(Function));
+ });
+ });
+ });
+});