Skip to content

Commit 06f3335

Browse files
authored
Add new OneTimePasswordField primitive (#3463)
1 parent c9d489d commit 06f3335

File tree

14 files changed

+1576
-1
lines changed

14 files changed

+1576
-1
lines changed

.changeset/four-webs-battle.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@radix-ui/react-one-time-password-field': minor
3+
'radix-ui': minor
4+
---
5+
6+
Introduce new One Time Password Field primitive
7+
8+
This new primitive is designed to implement the common design pattern for one-time password fields displayed as separate input fields for each character. This UI is deceptively complex to implement so that interactions follow user expectations. The new primitive handles all of this complexity for you, including:
9+
10+
- Keyboard navigation mimicking the behavior of a single input field
11+
- Overriding values on paste
12+
- Password manager autofill support
13+
- Input validation for numeric and alphanumeric values
14+
- Auto-submit on completion
15+
- Focus management
16+
- Hidden input to provide a single value to form data

apps/storybook/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"scripts": {
77
"lint": "eslint --max-warnings 0 .",
88
"dev": "storybook dev -p 6006",
9-
"build": "storybook build"
9+
"build": "storybook build",
10+
"typecheck": "tsc --noEmit"
1011
},
1112
"dependencies": {
1213
"@radix-ui/colors": "^3.0.0",
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
.viewport {
2+
display: flex;
3+
flex-direction: column;
4+
height: 100vh;
5+
width: 100vw;
6+
justify-content: center;
7+
align-items: center;
8+
}
9+
10+
.form {
11+
display: flex;
12+
flex-direction: column;
13+
gap: 1rem;
14+
}
15+
16+
.field {
17+
display: flex;
18+
flex-direction: column;
19+
gap: 0.75rem;
20+
}
21+
22+
.selectField {
23+
display: flex;
24+
flex-direction: column;
25+
gap: 0.25rem;
26+
}
27+
28+
.otpRoot {
29+
display: flex;
30+
gap: 0.5rem;
31+
align-items: center;
32+
}
33+
34+
:where(.otpRoot) input:not([type='hidden']) {
35+
--border-color: #ddd;
36+
--focus-ring-color: dodgerblue;
37+
height: 3rem;
38+
width: 2rem;
39+
text-align: center;
40+
font-size: 1em;
41+
padding: 0;
42+
box-shadow: none;
43+
border: 2px solid var(--border-color);
44+
border-radius: 4px;
45+
}
46+
47+
:where(.otpRoot[data-state='invalid']) input:not([type='hidden']) {
48+
--focus-ring-color: tomato;
49+
--border-color: crimson;
50+
}
51+
52+
:where(.otpRoot[data-state='valid']) input:not([type='hidden']) {
53+
--focus-ring-color: limegreen;
54+
--border-color: green;
55+
}
56+
57+
:where(.otpRoot[data-state='valid']) input:focus {
58+
outline: 2px solid var(--focus-ring-color);
59+
outline-offset: 0;
60+
}
61+
62+
.errorMessage {
63+
}
64+
65+
.separator {
66+
height: calc(100% - 8px);
67+
width: 1px;
68+
background-color: #ddd;
69+
}
70+
71+
.output {
72+
display: flex;
73+
flex-direction: column;
74+
gap: 0.5rem;
75+
align-items: center;
76+
justify-content: center;
77+
font-family: monospace;
78+
padding: 1rem;
79+
background-color: var(--gray-4);
80+
border: 1px solid var(--gray-6);
81+
border-radius: 4px;
82+
83+
&:where([data-state='valid']) {
84+
background-color: var(--green-5);
85+
border-color: var(--green-6);
86+
}
87+
88+
&:where([data-state='invalid']) {
89+
background-color: var(--red-5);
90+
border-color: var(--red-6);
91+
}
92+
}
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import * as React from 'react';
2+
import { unstable_OneTimePasswordField as OneTimePasswordField, Separator } from 'radix-ui';
3+
import { Dialog as DialogPrimitive } from 'radix-ui';
4+
import dialogStyles from './dialog.stories.module.css';
5+
import type { Meta, StoryObj } from '@storybook/react';
6+
import { userEvent, within, expect } from '@storybook/test';
7+
import styles from './one-time-password-field.stories.module.css';
8+
9+
export default {
10+
title: 'Components/OneTimePasswordField',
11+
component: OneTimePasswordField.Root,
12+
} satisfies Meta<typeof OneTimePasswordField.Root>;
13+
14+
type Story = StoryObj<typeof OneTimePasswordField.Root>;
15+
16+
type FormState = { type: 'idle' } | { type: 'valid' } | { type: 'invalid'; error: string };
17+
18+
const VALID_CODE = '123456';
19+
20+
const sharedStoryProps = {
21+
argTypes: {
22+
placeholder: {
23+
control: { type: 'text' },
24+
},
25+
validationType: {
26+
options: ['numeric', 'alphanumeric', 'alpha', 'none'],
27+
control: { type: 'select' },
28+
},
29+
autoSubmit: {
30+
control: { type: 'boolean' },
31+
},
32+
},
33+
} satisfies Story;
34+
35+
export const Uncontrolled = {
36+
...sharedStoryProps,
37+
render: (args) => <UncontrolledImpl {...args} />,
38+
} satisfies Story;
39+
40+
function UncontrolledImpl(props: OneTimePasswordField.OneTimePasswordFieldProps) {
41+
const [showSuccessMessage, setShowSuccessMessage] = React.useState(false);
42+
const rootRef = React.useRef<HTMLDivElement | null>(null);
43+
const [formState, setFormState] = React.useState<FormState>({ type: 'idle' });
44+
45+
return (
46+
<div className={styles.viewport}>
47+
<form
48+
className={styles.form}
49+
onSubmit={(event) => {
50+
const formData = new FormData(event.currentTarget);
51+
const code = formData.get('code') as string;
52+
event.preventDefault();
53+
if (code.length === VALID_CODE.length && code !== VALID_CODE) {
54+
setFormState({ type: 'invalid', error: 'Invalid code' });
55+
}
56+
//
57+
else if (code.length !== VALID_CODE.length) {
58+
setFormState({ type: 'invalid', error: 'Please fill in all fields' });
59+
}
60+
//
61+
else if (Math.random() > 0.675) {
62+
setFormState({ type: 'invalid', error: 'Server error' });
63+
}
64+
//
65+
else {
66+
setFormState({ type: 'valid' });
67+
setShowSuccessMessage(true);
68+
}
69+
}}
70+
>
71+
<div className={styles.field}>
72+
<OneTimePasswordField.Root
73+
data-state={formState.type}
74+
className={styles.otpRoot}
75+
autoFocus
76+
ref={rootRef}
77+
{...props}
78+
>
79+
<OneTimePasswordField.Input />
80+
<Separator.Root orientation="vertical" className={styles.separator} />
81+
<OneTimePasswordField.Input />
82+
<Separator.Root orientation="vertical" className={styles.separator} />
83+
<OneTimePasswordField.Input />
84+
<Separator.Root orientation="vertical" className={styles.separator} />
85+
<OneTimePasswordField.Input />
86+
<Separator.Root orientation="vertical" className={styles.separator} />
87+
<OneTimePasswordField.Input />
88+
<Separator.Root orientation="vertical" className={styles.separator} />
89+
<OneTimePasswordField.Input />
90+
91+
<OneTimePasswordField.HiddenInput name="code" />
92+
</OneTimePasswordField.Root>
93+
{formState.type === 'invalid' && <ErrorMessage>{formState.error}</ErrorMessage>}
94+
</div>
95+
<button type="reset">Reset form</button>
96+
<button>Submit</button>
97+
</form>
98+
<Dialog
99+
open={showSuccessMessage}
100+
onOpenChange={setShowSuccessMessage}
101+
title="Password match"
102+
content="Success!"
103+
/>
104+
</div>
105+
);
106+
}
107+
108+
export const Controlled = {
109+
...sharedStoryProps,
110+
render: (args) => <ControlledImpl {...args} />,
111+
} satisfies Story;
112+
113+
function ControlledImpl(props: OneTimePasswordField.OneTimePasswordFieldProps) {
114+
const [error, setError] = React.useState<string | null>(null);
115+
const [code, setCode] = React.useState('');
116+
const [showSuccessMessage, setShowSuccessMessage] = React.useState(false);
117+
const rootRef = React.useRef<HTMLDivElement | null>(null);
118+
const VALID_CODE = '123456';
119+
const isInvalid = code.length === VALID_CODE.length ? code !== VALID_CODE : false;
120+
const isValid = code.length === VALID_CODE.length ? code === VALID_CODE : false;
121+
return (
122+
<div className={styles.viewport}>
123+
<form
124+
className={styles.form}
125+
onSubmit={(event) => {
126+
event.preventDefault();
127+
if (isInvalid) {
128+
setError('Invalid code');
129+
}
130+
131+
//
132+
else if (code.length !== VALID_CODE.length) {
133+
setError('Please fill in all fields');
134+
}
135+
136+
//
137+
else if (Math.random() > 0.675) {
138+
setError('Server error');
139+
}
140+
141+
//
142+
else {
143+
setShowSuccessMessage(true);
144+
}
145+
}}
146+
>
147+
<div className={styles.field}>
148+
<OneTimePasswordField.Root
149+
data-state={error || isInvalid ? 'invalid' : isValid ? 'valid' : undefined}
150+
className={styles.otpRoot}
151+
autoFocus
152+
ref={rootRef}
153+
onValueChange={(value) => setCode(value)}
154+
value={code}
155+
{...props}
156+
>
157+
<OneTimePasswordField.Input />
158+
<Separator.Root orientation="vertical" className={styles.separator} />
159+
<OneTimePasswordField.Input />
160+
<Separator.Root orientation="vertical" className={styles.separator} />
161+
<OneTimePasswordField.Input />
162+
<Separator.Root orientation="vertical" className={styles.separator} />
163+
<OneTimePasswordField.Input />
164+
<Separator.Root orientation="vertical" className={styles.separator} />
165+
<OneTimePasswordField.Input />
166+
<Separator.Root orientation="vertical" className={styles.separator} />
167+
<OneTimePasswordField.Input />
168+
169+
<OneTimePasswordField.HiddenInput name="code" />
170+
</OneTimePasswordField.Root>
171+
{error && <ErrorMessage>{error}</ErrorMessage>}
172+
</div>
173+
<button type="button" onClick={() => setCode('')}>
174+
Reset state
175+
</button>
176+
<button type="reset">Reset form</button>
177+
<button>Submit</button>
178+
<output
179+
data-state={error || isInvalid ? 'invalid' : isValid ? 'valid' : undefined}
180+
className={styles.output}
181+
>
182+
{code || 'code'}
183+
</output>
184+
</form>
185+
<Dialog
186+
open={showSuccessMessage}
187+
onOpenChange={setShowSuccessMessage}
188+
title="Password match"
189+
content="Success!"
190+
/>
191+
</div>
192+
);
193+
}
194+
195+
export const PastedAndDeletedControlled: Story = {
196+
render: (args) => <ControlledImpl {...args} />,
197+
name: 'Pasted and deleted (controlled test)',
198+
play: async ({ canvasElement }) => {
199+
const canvas = within(canvasElement);
200+
const inputs = canvas.getAllByRole<HTMLInputElement>('textbox', {
201+
hidden: false,
202+
});
203+
204+
const firstInput = inputs[0]!;
205+
expect(firstInput).toBeInTheDocument();
206+
await userEvent.click(firstInput);
207+
await userEvent.paste('123123');
208+
expect(getInputValues(inputs)).toBe('1,2,3,1,2,3');
209+
await userEvent.keyboard('{backspace}{backspace}{backspace}{backspace}{backspace}');
210+
expect(getInputValues(inputs)).toBe('1,,,,,');
211+
await userEvent.keyboard('{backspace}');
212+
expect(getInputValues(inputs)).toBe(',,,,,');
213+
},
214+
};
215+
216+
export const PastedAndDeletedUncontrolled: Story = {
217+
render: (args) => <UncontrolledImpl {...args} />,
218+
name: 'Pasted and deleted (uncontrolled test)',
219+
play: async ({ canvasElement }) => {
220+
const canvas = within(canvasElement);
221+
const inputs = canvas.getAllByRole<HTMLInputElement>('textbox', {
222+
hidden: false,
223+
});
224+
225+
const firstInput = inputs[0]!;
226+
expect(firstInput).toBeInTheDocument();
227+
await userEvent.click(firstInput);
228+
await userEvent.paste('123123');
229+
expect(getInputValues(inputs)).toBe('1,2,3,1,2,3');
230+
await userEvent.keyboard('{backspace}{backspace}{backspace}{backspace}{backspace}');
231+
expect(getInputValues(inputs)).toBe('1,,,,,');
232+
await userEvent.keyboard('{backspace}');
233+
// With the current bug, the actual behavior is 1,2,3,1,2,3
234+
expect(getInputValues(inputs)).toBe(',,,,,');
235+
236+
// if we keep deleting while the first input is focused, we eventually get ,2,3,1,2,3
237+
},
238+
};
239+
240+
function ErrorMessage({ children }: { children: string }) {
241+
return <div className={styles.errorMessage}>{children}</div>;
242+
}
243+
244+
function Dialog({
245+
trigger,
246+
title = 'Hello!',
247+
content,
248+
open,
249+
onOpenChange,
250+
}: {
251+
title?: string;
252+
content: string;
253+
trigger?: React.ReactNode;
254+
open: boolean;
255+
onOpenChange: (open: boolean) => void;
256+
}) {
257+
const lastFocusedRef = React.useRef<HTMLElement | null>(null);
258+
React.useLayoutEffect(() => {
259+
lastFocusedRef.current = document.activeElement as HTMLElement;
260+
}, []);
261+
return (
262+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
263+
{trigger}
264+
<DialogPrimitive.Portal>
265+
<DialogPrimitive.Overlay className={dialogStyles.overlay} />
266+
<DialogPrimitive.Content className={dialogStyles.contentDefault}>
267+
<DialogPrimitive.Title>{title}</DialogPrimitive.Title>
268+
<DialogPrimitive.Description>{content}</DialogPrimitive.Description>
269+
<DialogPrimitive.Close className={dialogStyles.close}>close</DialogPrimitive.Close>
270+
</DialogPrimitive.Content>
271+
</DialogPrimitive.Portal>
272+
</DialogPrimitive.Root>
273+
);
274+
}
275+
276+
function getInputValues(inputs: HTMLInputElement[]) {
277+
return inputs.map((input) => input.value).join(',');
278+
}

0 commit comments

Comments
 (0)