Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/storybook/stories/one-time-password-field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,62 @@ function ControlledImpl(props: OneTimePasswordField.OneTimePasswordFieldProps) {
);
}

export const ControlledWithVirtualKeyboard: Story = {
render: (args) => <ControlledWithPresetAndVirtualKeyboard {...args} />,
name: 'Controlled with preset and virtual keyboard',
};

function ControlledWithPresetAndVirtualKeyboard(
props: OneTimePasswordField.OneTimePasswordFieldProps
) {
const [code, setCode] = React.useState('1');
const rootRef = React.useRef<HTMLDivElement | null>(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 (
<div className={styles.viewport}>
<div className={styles.field}>
<OneTimePasswordField.Root
className={styles.otpRoot}
autoFocus
ref={rootRef}
onValueChange={(value) => setCode(value)}
value={code}
{...props}
>
<OneTimePasswordField.Input />
<Separator.Root orientation="vertical" className={styles.separator} />
<OneTimePasswordField.Input />
<Separator.Root orientation="vertical" className={styles.separator} />
<OneTimePasswordField.Input />
<Separator.Root orientation="vertical" className={styles.separator} />
<OneTimePasswordField.Input />
<Separator.Root orientation="vertical" className={styles.separator} />
<OneTimePasswordField.Input />
<Separator.Root orientation="vertical" className={styles.separator} />
<OneTimePasswordField.Input />

<OneTimePasswordField.HiddenInput name="code" />
</OneTimePasswordField.Root>
</div>

<button type="button" onClick={appendRandomDigit} disabled={code.length >= 6}>
Add random digit
</button>

<output className={styles.output}>{code || 'code'}</output>
</div>
);
}

export const PastedAndDeletedControlled: Story = {
render: (args) => <ControlledImpl {...args} />,
name: 'Pasted and deleted (controlled test)',
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 }) => (
<OneTimePasswordField.Root value={value} onValueChange={() => {}} autoFocus>
<OneTimePasswordField.Input />
<OneTimePasswordField.Input />
<OneTimePasswordField.Input />
<OneTimePasswordField.Input />
<OneTimePasswordField.Input />
<OneTimePasswordField.Input />
<OneTimePasswordField.HiddenInput />
</OneTimePasswordField.Root>
);

const { rerender } = render(<Test value="" />);
const inputs = screen.getAllByRole<HTMLInputElement>('textbox', { hidden: false });

rerender(<Test value="0" />);
await waitFor(() => {
expect(inputs[1]).toHaveFocus();
});

rerender(<Test value="012" />);
await waitFor(() => {
expect(inputs[3]).toHaveFocus();
});
});

it('clamps focus to the last input when value length exceeds inputs', async () => {
const Test = ({ value }: { value: string }) => (
<OneTimePasswordField.Root value={value} onValueChange={() => {}} autoFocus>
<OneTimePasswordField.Input />
<OneTimePasswordField.Input />
<OneTimePasswordField.Input />
<OneTimePasswordField.Input />
<OneTimePasswordField.Input />
<OneTimePasswordField.Input />
<OneTimePasswordField.HiddenInput />
</OneTimePasswordField.Root>
);

const { rerender } = render(<Test value="" />);
const inputs = screen.getAllByRole<HTMLInputElement>('textbox', { hidden: false });
const lastIndex = inputs.length - 1;

rerender(<Test value="0123456" />);
await waitFor(() => {
expect(inputs[lastIndex]).toHaveFocus();
});
});
});

function getInputValues(inputs: HTMLInputElement[]) {
return inputs.map((input) => input.value).join(',');
}
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,29 @@ const OneTimePasswordField = React.forwardRef<HTMLDivElement, OneTimePasswordFie
}, [attemptSubmit, autoSubmit, currentValue, length, onAutoSubmit, value]);
const isHydrated = useIsHydrated();

// Sync focus to controlled value changes when controlled or autoFocus is true
React.useEffect(() => {
// 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 (
<OneTimePasswordFieldContext
scope={__scopeOneTimePasswordField}
Expand Down