fix otp paste not filling all fields on mobile

This commit is contained in:
Luca Seemann committed 2026-08-20 01:23:02 +02:00
1 parent c3a70b123d
commit c1c19ca24b
4 files changed
+76 -13

No files matched your search

+6 -1
View File
@@ -41,7 +41,12 @@ export function LoginForm() {
<Logo />
</Icon>
<Heading>umami</Heading>
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} style={{ minWidth: 300 }}>
<Form
onSubmit={handleSubmit}
error={getErrorMessage(error)}
defaultValues={{ username: '', password: '' }}
style={{ minWidth: 300 }}
>
<FormField
label={t(labels.username)}
data-test="input-username"
@@ -100,7 +100,12 @@ export function LoginTwoFactorPage() {
<Text style={{ color: 'var(--color-danger, red)' }}>{lockMessage}</Text>
)}
<Form onSubmit={handleSubmit} error={error ?? undefined} style={{ minWidth: 300 }}>
<Form
onSubmit={handleSubmit}
error={error ?? undefined}
defaultValues={{ backupCode: '' }}
style={{ minWidth: 300 }}
>
{!useBackup ? (
<Column gap="2">
<Text weight="bold">{t(labels.twoFactorEnterCode)}</Text>
+42
View File
@@ -0,0 +1,42 @@
import { fireEvent } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { render, screen } from '@/test/render';
import { OtpInput } from './OtpInput';
function getDigitInput(index: number) {
return screen.getByLabelText(`Digit ${index + 1}`) as HTMLInputElement;
}
test('typing individual digits fills fields in order and advances focus', async () => {
const onChange = vi.fn();
const { user } = render(<OtpInput value="" onChange={onChange} />);
await user.click(getDigitInput(0));
await user.keyboard('1');
expect(onChange).toHaveBeenLastCalledWith('1');
});
test('a real paste event distributes the code across all fields and calls onComplete', async () => {
const onChange = vi.fn();
const onComplete = vi.fn();
const { user } = render(<OtpInput value="" onChange={onChange} onComplete={onComplete} />);
await user.click(getDigitInput(0));
await user.paste('123456');
expect(onChange).toHaveBeenLastCalledWith('123456');
expect(onComplete).toHaveBeenCalledWith('123456');
});
test('a multi-character value delivered via onChange (mobile paste fallback) fills all fields', () => {
const onChange = vi.fn();
const onComplete = vi.fn();
render(<OtpInput value="" onChange={onChange} onComplete={onComplete} />);
const input = getDigitInput(0);
fireEvent.change(input, { target: { value: '123456' } });
expect(onChange).toHaveBeenLastCalledWith('123456');
expect(onComplete).toHaveBeenCalledWith('123456');
});
+22 -11
View File
@@ -27,8 +27,28 @@ export function OtpInput({ value, onChange, onComplete, disabled }: OtpInputProp
}
};
const applyPastedValue = (digitsStr: string) => {
const pasted = digitsStr.replace(/\D/g, '').slice(0, LENGTH);
const next = Array.from({ length: LENGTH }, (_, i) => pasted[i] ?? '');
const newVal = next.join('');
onChange(newVal);
if (newVal.length === LENGTH) {
onComplete?.(newVal);
focusInput(refs.current[LENGTH - 1]);
} else {
focusInput(refs.current[pasted.length]);
}
};
const handleInput = (index: number, raw: string) => {
const char = raw.replace(/\D/g, '').slice(-1);
const digitsOnly = raw.replace(/\D/g, '');
if (digitsOnly.length > 1) {
applyPastedValue(digitsOnly);
return;
}
const char = digitsOnly;
if (!char) return;
update(index, char);
if (index < LENGTH - 1) {
@@ -56,16 +76,7 @@ export function OtpInput({ value, onChange, onComplete, disabled }: OtpInputProp
const handlePaste = (e: ClipboardEvent<HTMLInputElement & HTMLTextAreaElement>) => {
e.preventDefault();
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, LENGTH);
const next = Array.from({ length: LENGTH }, (_, i) => pasted[i] ?? '');
const newVal = next.join('');
onChange(newVal);
if (newVal.length === LENGTH) {
onComplete?.(newVal);
focusInput(refs.current[LENGTH - 1]);
} else {
focusInput(refs.current[pasted.length]);
}
applyPastedValue(e.clipboardData.getData('text'));
};
return (