diff --git a/.changeset/twenty-camels-drum.md b/.changeset/twenty-camels-drum.md new file mode 100644 index 00000000000..348a0b46b9b --- /dev/null +++ b/.changeset/twenty-camels-drum.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Enhanced detection of password manangers diff --git a/packages/clerk-js/src/ui/elements/CodeControl.tsx b/packages/clerk-js/src/ui/elements/CodeControl.tsx index 77ee4ac3366..051cac1f904 100644 --- a/packages/clerk-js/src/ui/elements/CodeControl.tsx +++ b/packages/clerk-js/src/ui/elements/CodeControl.tsx @@ -196,6 +196,19 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { } }, [values]); + // Focus management for password managers + React.useEffect(() => { + const handleFocus = () => { + // If focus is on the hidden input, redirect to first visible input + if (document.activeElement === hiddenInputRef.current) { + setTimeout(() => focusInputAt(0), 0); + } + }; + + document.addEventListener('focusin', handleFocus); + return () => document.removeEventListener('focusin', handleFocus); + }, []); + const handleMultipleCharValue = ({ eventValue, inputPosition }: { eventValue: string; inputPosition: number }) => { const eventValues = (eventValue || '').split(''); @@ -311,13 +324,20 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { ref={hiddenInputRef} type='text' autoComplete='one-time-code' - data-otp-hidden-input inputMode='numeric' pattern={`[0-9]{${length}}`} minLength={length} maxLength={length} spellCheck={false} - aria-hidden='true' + name='otp' + id='otp-input' + data-otp-input + data-otp-hidden-input + data-testid='otp-input' + role='textbox' + aria-label='One-time password input for password managers' + aria-describedby='otp-instructions' + aria-hidden tabIndex={-1} onChange={handleHiddenInputChange} onFocus={() => { @@ -325,12 +345,41 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { focusInputAt(0); }} sx={() => ({ - ...common.visuallyHidden(), - left: '-9999px', + // NOTE: Do not use the visuallyHidden() utility here, as it will break password manager autofill + position: 'absolute', + opacity: 0, + width: '1px', + height: '1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + // Ensure the input is still accessible to password managers + // by not using display: none or visibility: hidden pointerEvents: 'none', + // Position slightly within the container for better detection + top: 0, + left: 0, })} /> + {/* Hidden instructions for screen readers and password managers */} + + Enter the {length}-digit verification code + + ((_, ref) => { sx={t => ({ direction: 'ltr', padding: t.space.$1, marginLeft: `calc(${t.space.$1} * -1)`, ...centerSx })} role='group' aria-label='Verification code input' + aria-describedby='otp-instructions' > {values.map((value: string, index: number) => ( ((_, ref) => { type='text' inputMode='numeric' name={`codeInput-${index}`} - data-otp-segment - data-1p-ignore + data-otp-segment='true' + data-1p-ignore='true' data-lpignore='true' maxLength={1} pattern='[0-9]' diff --git a/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx b/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx index 41ffea4d0ad..d8ada2a736b 100644 --- a/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx +++ b/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx @@ -328,19 +328,85 @@ describe('CodeControl', () => { // Simulate autofill with mixed characters if (hiddenInput) { - fireEvent.change(hiddenInput, { target: { value: '1a2b3c4d5e6f' } }); + fireEvent.change(hiddenInput, { target: { value: '1a2b3c' } }); } 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'); + expect(visibleInputs[3]).toHaveValue(''); + expect(visibleInputs[4]).toHaveValue(''); + expect(visibleInputs[5]).toHaveValue(''); }); }); + it('has proper password manager attributes for detection', 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]'); + + // Verify critical attributes for detection + 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('name', 'otp'); + expect(hiddenInput).toHaveAttribute('id', 'otp-input'); + expect(hiddenInput).toHaveAttribute('data-otp-input'); + expect(hiddenInput).toHaveAttribute('role', 'textbox'); + expect(hiddenInput).toHaveAttribute('aria-label', 'One-time password input for password managers'); + expect(hiddenInput).toHaveAttribute('aria-hidden', 'true'); + expect(hiddenInput).toHaveAttribute('data-testid', 'otp-input'); + }); + + it('handles focus redirection from hidden input to visible inputs', 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]') as HTMLInputElement; + const visibleInputs = container.querySelectorAll('[data-otp-segment]'); + + // Focus the hidden input (simulating password manager behavior) + hiddenInput.focus(); + + await waitFor(() => { + // Should redirect focus to first visible input + expect(visibleInputs[0]).toHaveFocus(); + }); + }); + + it('maintains accessibility with proper ARIA attributes', 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 inputContainer = container.querySelector('[role="group"]'); + const instructions = container.querySelector('#otp-instructions'); + + // Verify ARIA setup - some attributes might be filtered by the Input component + expect(hiddenInput).toHaveAttribute('aria-hidden', 'true'); + expect(inputContainer).toHaveAttribute('aria-describedby', 'otp-instructions'); + expect(instructions).toHaveTextContent('Enter the 6-digit verification code'); + + // Check for any aria-describedby attribute (it might be there but not exactly as expected) + const ariaDescribedBy = hiddenInput?.getAttribute('aria-describedby'); + if (ariaDescribedBy) { + expect(ariaDescribedBy).toBe('otp-instructions'); + } + }); + it('focuses first visible input when hidden input is focused', async () => { const { wrapper } = await createFixtures(); const onCodeEntryFinished = vi.fn(); diff --git a/packages/elements/src/react/common/form/hooks/use-input.tsx b/packages/elements/src/react/common/form/hooks/use-input.tsx index 3b17e340dba..2500b6eb24b 100644 --- a/packages/elements/src/react/common/form/hooks/use-input.tsx +++ b/packages/elements/src/react/common/form/hooks/use-input.tsx @@ -183,6 +183,13 @@ export function useInput({ pattern: `[0-9]{${length}}`, minLength: length, maxLength: length, + // Enhanced naming for better password manager detection + name: 'otp', + id: 'otp-input', + // Additional attributes for password manager compatibility + 'data-testid': 'otp-input', + role: 'textbox', + 'aria-label': 'Enter verification code', onChange: (event: React.ChangeEvent) => { // Only accept numbers event.currentTarget.value = event.currentTarget.value.replace(/\D+/g, '');