From 643cc5c11729a84bf70636deb21098f0557bc984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B8=D0=B4=D0=BE=D1=80=D1=83=D0=BA=20=D0=A1=D0=B5?= =?UTF-8?q?=D1=80=D0=B3=D1=96=D0=B9=20=D0=92=D1=96=D1=82=D0=B0=D0=BB=D1=96?= =?UTF-8?q?=D0=B9=D0=BE=D0=B2=D0=B8=D1=87?= Date: Thu, 25 Sep 2025 09:36:22 +0300 Subject: [PATCH 1/2] [OneTimePasswordField] sync focus position with controlled value change --- .../src/one-time-password-field.test.tsx | 56 ++++++++++++++++++- .../src/one-time-password-field.tsx | 23 ++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/react/one-time-password-field/src/one-time-password-field.test.tsx b/packages/react/one-time-password-field/src/one-time-password-field.test.tsx index 94fa3ef38..55029cfd7 100644 --- a/packages/react/one-time-password-field/src/one-time-password-field.test.tsx +++ b/packages/react/one-time-password-field/src/one-time-password-field.test.tsx @@ -1,6 +1,6 @@ import { axe } from 'vitest-axe'; import type { RenderResult } from '@testing-library/react'; -import { act, cleanup, render, screen, fireEvent } from '@testing-library/react'; +import { act, cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'; import * as OneTimePasswordField from './one-time-password-field'; import { afterEach, describe, it, beforeEach, expect } from 'vitest'; import { userEvent, type UserEvent } from '@testing-library/user-event'; @@ -81,6 +81,60 @@ describe('given a default OneTimePasswordField', () => { }); }); +describe('given a controlled value to OneTimePasswordField', () => { + afterEach(cleanup); + + it('focuses the input at clamp(value.length, 0, lastIndex) as value grows', async () => { + const Test = ({ value }: { value: string }) => ( + {}} autoFocus> + + + + + + + + + ); + + const { rerender } = render(); + const inputs = screen.getAllByRole('textbox', { hidden: false }); + + rerender(); + await waitFor(() => { + expect(inputs[1]).toHaveFocus(); + }); + + rerender(); + await waitFor(() => { + expect(inputs[3]).toHaveFocus(); + }); + }); + + it('clamps focus to the last input when value length exceeds inputs', async () => { + const Test = ({ value }: { value: string }) => ( + {}} autoFocus> + + + + + + + + + ); + + const { rerender } = render(); + const inputs = screen.getAllByRole('textbox', { hidden: false }); + const lastIndex = inputs.length - 1; + + rerender(); + await waitFor(() => { + expect(inputs[lastIndex]).toHaveFocus(); + }); + }); +}); + function getInputValues(inputs: HTMLInputElement[]) { return inputs.map((input) => input.value).join(','); } diff --git a/packages/react/one-time-password-field/src/one-time-password-field.tsx b/packages/react/one-time-password-field/src/one-time-password-field.tsx index ad7c4b596..3aa2113f5 100644 --- a/packages/react/one-time-password-field/src/one-time-password-field.tsx +++ b/packages/react/one-time-password-field/src/one-time-password-field.tsx @@ -430,6 +430,29 @@ const OneTimePasswordField = React.forwardRef { + // Run when: + // - autoFocus is true (explicit opt-in for uncontrolled), OR + // - component is controlled (valueProp provided) so external updates keep focus in sync + const isControlled = valueProp != null; + if (!autoFocus && !isControlled) { + return; + } + const totalValue = value.join(''); + const nextIndex = clamp(totalValue.length, [0, collection.size - 1]); + const active = (rootRef.current?.ownerDocument ?? document).activeElement as Element | null; + const activeIndex = collection.indexOf(active as HTMLInputElement); + if (activeIndex === nextIndex) { + return; + } + const target = collection.at(nextIndex)?.element ?? null; + // Defer to the next frame to avoid click-to-blur conflicts (e.g. with virtual keyboards) + requestAnimationFrame(() => { + focusInput(target ?? undefined); + }); + }, [autoFocus, collection, value, valueProp]); + return ( Date: Thu, 25 Sep 2025 10:51:44 +0300 Subject: [PATCH 2/2] example story added --- .../one-time-password-field.stories.tsx | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/apps/storybook/stories/one-time-password-field.stories.tsx b/apps/storybook/stories/one-time-password-field.stories.tsx index 7c204dd83..09e4450f0 100644 --- a/apps/storybook/stories/one-time-password-field.stories.tsx +++ b/apps/storybook/stories/one-time-password-field.stories.tsx @@ -192,6 +192,62 @@ function ControlledImpl(props: OneTimePasswordField.OneTimePasswordFieldProps) { ); } +export const ControlledWithVirtualKeyboard: Story = { + render: (args) => , + name: 'Controlled with preset and virtual keyboard', +}; + +function ControlledWithPresetAndVirtualKeyboard( + props: OneTimePasswordField.OneTimePasswordFieldProps +) { + const [code, setCode] = React.useState('1'); + const rootRef = React.useRef(null); + + const appendRandomDigit = React.useCallback(() => { + if (code.length >= 6) { + return; + } + + const randomDigit = String(Math.floor(Math.random() * 10)); + setCode((previous) => previous + randomDigit); + }, [code.length]); + + return ( +
+
+ setCode(value)} + value={code} + {...props} + > + + + + + + + + + + + + + + +
+ + + + {code || 'code'} +
+ ); +} + export const PastedAndDeletedControlled: Story = { render: (args) => , name: 'Pasted and deleted (controlled test)',