From 37dc8c6cc1e9e4b74b00ce02c3a4eaa1e17480a6 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 2 Jul 2025 17:26:23 -0400 Subject: [PATCH 1/5] feat(clerk-js): Password manager autofill OTP codes --- .changeset/polite-pants-talk.md | 5 + .../clerk-js/src/ui/elements/CodeControl.tsx | 131 +++++++++++++----- .../clerk-js/src/ui/foundations/opacity.ts | 1 + 3 files changed, 105 insertions(+), 32 deletions(-) create mode 100644 .changeset/polite-pants-talk.md diff --git a/.changeset/polite-pants-talk.md b/.changeset/polite-pants-talk.md new file mode 100644 index 00000000000..8de871f29cd --- /dev/null +++ b/.changeset/polite-pants-talk.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +Password managers will now autofill OTP code verifications. diff --git a/packages/clerk-js/src/ui/elements/CodeControl.tsx b/packages/clerk-js/src/ui/elements/CodeControl.tsx index c777ae69a36..85786431c11 100644 --- a/packages/clerk-js/src/ui/elements/CodeControl.tsx +++ b/packages/clerk-js/src/ui/elements/CodeControl.tsx @@ -6,6 +6,7 @@ import type { LocalizationKey } from '../customizables'; import { descriptors, Flex, Input } from '../customizables'; import { useCardState } from '../elements/contexts'; import { useLoadingStatus } from '../hooks'; +import { Box } from '../primitives'; import type { PropsOfComponent } from '../styledSystem'; import { common, mqu } from '../styledSystem'; import { handleError } from '../utils/errorHandler'; @@ -130,6 +131,7 @@ export type OTPInputProps = { onResendCode?: React.MouseEventHandler; otpControl: ReturnType['otpControl']; centerAlign?: boolean; + passwordManagerOffset?: number; }; const [OTPInputContext, useOTPInputContext] = createContextAndHook('OTPInputContext'); @@ -160,15 +162,21 @@ export const OTPResendButton = () => { export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { const [disabled, setDisabled] = React.useState(false); const refs = React.useRef>([]); + const hiddenInputRef = React.useRef(null); const firstClickRef = React.useRef(false); - const { otpControl, isLoading, isDisabled, centerAlign = true } = useOTPInputContext(); + const { otpControl, isLoading, isDisabled, centerAlign = true, passwordManagerOffset = 40 } = useOTPInputContext(); const { feedback, values, setValues, feedbackType, length } = otpControl.otpInputProps; React.useImperativeHandle(ref, () => ({ reset: () => { setValues(values.map(() => '')); setDisabled(false); + + if (hiddenInputRef.current) { + hiddenInputRef.current.value = ''; + } + setTimeout(() => focusInputAt(0), 0); }, })); @@ -183,6 +191,13 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { } }, [feedback]); + // Update hidden input when values change + React.useEffect(() => { + if (hiddenInputRef.current) { + hiddenInputRef.current.value = values.join(''); + } + }, [values]); + const handleMultipleCharValue = ({ eventValue, inputPosition }: { eventValue: string; inputPosition: number }) => { const eventValues = (eventValue || '').split(''); @@ -274,40 +289,92 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { } }; + // Handle hidden input changes (for password manager autofill) + const handleHiddenInputChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/\D/g, '').slice(0, length); + const newValues = value.split('').concat(Array.from({ length: length - value.length }, () => '')); + setValues(newValues); + + // Focus the appropriate visible input + if (value.length > 0) { + focusInputAt(Math.min(value.length - 1, length - 1)); + } + }; + const centerSx = centerAlign ? { justifyContent: 'center', alignItems: 'center' } : {}; return ( - ({ direction: 'ltr', padding: t.space.$1, marginLeft: `-${t.space.$1}`, ...centerSx })} - > - {values.map((value, index: number) => ( - (refs.current[index] = node)} - autoFocus={index === 0 || undefined} - autoComplete='one-time-code' - aria-label={`${index === 0 ? 'Enter verification code. ' : ''}Digit ${index + 1}`} - isDisabled={isDisabled || isLoading || disabled || feedbackType === 'success'} - hasError={feedbackType === 'error'} - isSuccessfullyFilled={feedbackType === 'success'} - type='text' - inputMode='numeric' - name={`codeInput-${index}`} - /> - ))} - + + {/* Hidden input for password manager compatibility */} + { + // When password manager focuses the hidden input, focus the first visible input + focusInputAt(0); + }} + sx={theme => ({ + position: 'absolute', + left: '-9999px', + width: `calc(1px + ${passwordManagerOffset}px)`, + height: '1px', + opacity: theme.opacity.$hidden, + pointerEvents: 'none', + clipPath: `inset(0 ${passwordManagerOffset}px 0 0)`, + })} + /> + + ({ direction: 'ltr', padding: t.space.$1, marginLeft: `-${t.space.$1}`, ...centerSx })} + role='group' + aria-label='Verification code input' + > + {values.map((value: string, index: number) => ( + (refs.current[index] = node)} + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={index === 0 || undefined} + autoComplete='off' + aria-label={`${index === 0 ? 'Enter verification code. ' : ''}Digit ${index + 1}`} + isDisabled={isDisabled || isLoading || disabled || feedbackType === 'success'} + hasError={feedbackType === 'error'} + isSuccessfullyFilled={feedbackType === 'success'} + type='text' + inputMode='numeric' + name={`codeInput-${index}`} + data-otp-segment + data-1p-ignore + data-lpignore='true' + maxLength={1} + pattern='[0-9]' + /> + ))} + + ); }); diff --git a/packages/clerk-js/src/ui/foundations/opacity.ts b/packages/clerk-js/src/ui/foundations/opacity.ts index 57597c72055..8de5dda1d37 100644 --- a/packages/clerk-js/src/ui/foundations/opacity.ts +++ b/packages/clerk-js/src/ui/foundations/opacity.ts @@ -1,4 +1,5 @@ export const opacity = Object.freeze({ + hidden: '0%', sm: '24%', disabled: '50%', inactive: '62%', From 7b149a8793f2319d512c64fb82b176f9cd4f7f96 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 2 Jul 2025 17:36:48 -0400 Subject: [PATCH 2/5] chore: Import Box from customizables --- packages/clerk-js/src/ui/elements/CodeControl.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/clerk-js/src/ui/elements/CodeControl.tsx b/packages/clerk-js/src/ui/elements/CodeControl.tsx index 85786431c11..245ca356d12 100644 --- a/packages/clerk-js/src/ui/elements/CodeControl.tsx +++ b/packages/clerk-js/src/ui/elements/CodeControl.tsx @@ -3,10 +3,9 @@ import type { PropsWithChildren } from 'react'; import React, { useCallback } from 'react'; import type { LocalizationKey } from '../customizables'; -import { descriptors, Flex, Input } from '../customizables'; +import { Box, descriptors, Flex, Input } from '../customizables'; import { useCardState } from '../elements/contexts'; import { useLoadingStatus } from '../hooks'; -import { Box } from '../primitives'; import type { PropsOfComponent } from '../styledSystem'; import { common, mqu } from '../styledSystem'; import { handleError } from '../utils/errorHandler'; From 70f09889efecdf2d25a7a7f05bc7ba09ca46664a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 3 Jul 2025 15:48:53 -0400 Subject: [PATCH 3/5] chore(clerk-js): Add tests for `CodeControl` component (#6254) --- .../elements/__tests__/CodeControl.spec.tsx | 591 ++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx 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)); + }); + }); + }); +}); From 13a1eec19a09d01ab0108825ce81f3341ab9deb4 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 3 Jul 2025 17:39:08 -0400 Subject: [PATCH 4/5] chore: Address PR comments --- .../src/ui/customizables/elementDescriptors.ts | 1 + packages/clerk-js/src/ui/elements/CodeControl.tsx | 13 ++++++------- packages/clerk-js/src/ui/foundations/opacity.ts | 1 - packages/types/src/appearance.ts | 1 + 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 690efe896a6..0dec7cc9338 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -98,6 +98,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'otpCodeField', 'otpCodeFieldInputs', 'otpCodeFieldInput', + 'otpCodeFieldInputContainer', 'otpCodeFieldErrorText', 'formResendCodeLink', diff --git a/packages/clerk-js/src/ui/elements/CodeControl.tsx b/packages/clerk-js/src/ui/elements/CodeControl.tsx index 245ca356d12..228f27c41de 100644 --- a/packages/clerk-js/src/ui/elements/CodeControl.tsx +++ b/packages/clerk-js/src/ui/elements/CodeControl.tsx @@ -303,7 +303,10 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { const centerSx = centerAlign ? { justifyContent: 'center', alignItems: 'center' } : {}; return ( - + {/* Hidden input for password manager compatibility */} ((_, ref) => { // When password manager focuses the hidden input, focus the first visible input focusInputAt(0); }} - sx={theme => ({ - position: 'absolute', + sx={() => ({ + ...common.visuallyHidden(), left: '-9999px', - width: `calc(1px + ${passwordManagerOffset}px)`, - height: '1px', - opacity: theme.opacity.$hidden, pointerEvents: 'none', - clipPath: `inset(0 ${passwordManagerOffset}px 0 0)`, })} /> diff --git a/packages/clerk-js/src/ui/foundations/opacity.ts b/packages/clerk-js/src/ui/foundations/opacity.ts index 8de5dda1d37..57597c72055 100644 --- a/packages/clerk-js/src/ui/foundations/opacity.ts +++ b/packages/clerk-js/src/ui/foundations/opacity.ts @@ -1,5 +1,4 @@ export const opacity = Object.freeze({ - hidden: '0%', sm: '24%', disabled: '50%', inactive: '62%', diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index dc647795bee..a4e427dfd3b 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -218,6 +218,7 @@ export type ElementsConfig = { otpCodeField: WithOptions; otpCodeFieldInputs: WithOptions; otpCodeFieldInput: WithOptions; + otpCodeFieldInputContainer: WithOptions; otpCodeFieldErrorText: WithOptions; dividerRow: WithOptions; From b377cdb7da7dd30968224e2d531e1b00c59651a7 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 3 Jul 2025 17:55:09 -0400 Subject: [PATCH 5/5] chore: Remove unused passwordManagerOffset prop --- integration/templates/elements-next/src/app/otp/page.tsx | 1 - integration/tests/elements/otp.test.ts | 7 ------- packages/clerk-js/src/ui/elements/CodeControl.tsx | 3 +-- 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/integration/templates/elements-next/src/app/otp/page.tsx b/integration/templates/elements-next/src/app/otp/page.tsx index 57dac7018e8..60447b7dc6f 100644 --- a/integration/templates/elements-next/src/app/otp/page.tsx +++ b/integration/templates/elements-next/src/app/otp/page.tsx @@ -87,7 +87,6 @@ export default function OTP() { className='segmented-otp-with-props-wrapper flex justify-center has-[:disabled]:opacity-50' type='otp' data-testid='segmented-otp-with-props' - passwordManagerOffset={4} length={4} render={({ value, status }) => { return ( diff --git a/integration/tests/elements/otp.test.ts b/integration/tests/elements/otp.test.ts index 47b6da387f4..59f63f3414f 100644 --- a/integration/tests/elements/otp.test.ts +++ b/integration/tests/elements/otp.test.ts @@ -221,12 +221,5 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('OTP @elem // Check that only 4 segments are rendered await expect(otpSegmentsWrapper.locator('> div')).toHaveCount(4); }); - - test('passwordManagerOffset', async ({ page }) => { - const otp = page.getByTestId(otpTypes.segmentedOtpWithProps); - - // The computed styles are different on CI/local etc. so it's not use to check the exact value - await expect(otp).toHaveCSS('clip-path', /inset\(0px \d+\.\d+px 0px 0px\)/i); - }); }); }); diff --git a/packages/clerk-js/src/ui/elements/CodeControl.tsx b/packages/clerk-js/src/ui/elements/CodeControl.tsx index 228f27c41de..7e5118ed273 100644 --- a/packages/clerk-js/src/ui/elements/CodeControl.tsx +++ b/packages/clerk-js/src/ui/elements/CodeControl.tsx @@ -130,7 +130,6 @@ export type OTPInputProps = { onResendCode?: React.MouseEventHandler; otpControl: ReturnType['otpControl']; centerAlign?: boolean; - passwordManagerOffset?: number; }; const [OTPInputContext, useOTPInputContext] = createContextAndHook('OTPInputContext'); @@ -164,7 +163,7 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { const hiddenInputRef = React.useRef(null); const firstClickRef = React.useRef(false); - const { otpControl, isLoading, isDisabled, centerAlign = true, passwordManagerOffset = 40 } = useOTPInputContext(); + const { otpControl, isLoading, isDisabled, centerAlign = true } = useOTPInputContext(); const { feedback, values, setValues, feedbackType, length } = otpControl.otpInputProps; React.useImperativeHandle(ref, () => ({