Skip to content

Commit 322456f

Browse files
authored
feat: use OTP input from shadcn/ui (#717)
1 parent d1ab7d2 commit 322456f

File tree

7 files changed

+168
-25
lines changed

7 files changed

+168
-25
lines changed

app/components/forms.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { useInputControl } from '@conform-to/react'
2+
import { REGEXP_ONLY_DIGITS_AND_CHARS, type OTPInputProps } from 'input-otp'
23
import React, { useId } from 'react'
34
import { Checkbox, type CheckboxProps } from './ui/checkbox.tsx'
5+
import {
6+
InputOTP,
7+
InputOTPGroup,
8+
InputOTPSeparator,
9+
InputOTPSlot,
10+
} from './ui/input-otp.tsx'
411
import { Input } from './ui/input.tsx'
512
import { Label } from './ui/label.tsx'
613
import { Textarea } from './ui/textarea.tsx'
@@ -57,6 +64,50 @@ export function Field({
5764
)
5865
}
5966

67+
export function OTPField({
68+
labelProps,
69+
inputProps,
70+
errors,
71+
className,
72+
}: {
73+
labelProps: React.LabelHTMLAttributes<HTMLLabelElement>
74+
inputProps: Partial<OTPInputProps & { render: never }>
75+
errors?: ListOfErrors
76+
className?: string
77+
}) {
78+
const fallbackId = useId()
79+
const id = inputProps.id ?? fallbackId
80+
const errorId = errors?.length ? `${id}-error` : undefined
81+
return (
82+
<div className={className}>
83+
<Label htmlFor={id} {...labelProps} />
84+
<InputOTP
85+
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
86+
maxLength={6}
87+
id={id}
88+
aria-invalid={errorId ? true : undefined}
89+
aria-describedby={errorId}
90+
{...inputProps}
91+
>
92+
<InputOTPGroup>
93+
<InputOTPSlot index={0} />
94+
<InputOTPSlot index={1} />
95+
<InputOTPSlot index={2} />
96+
</InputOTPGroup>
97+
<InputOTPSeparator />
98+
<InputOTPGroup>
99+
<InputOTPSlot index={3} />
100+
<InputOTPSlot index={4} />
101+
<InputOTPSlot index={5} />
102+
</InputOTPGroup>
103+
</InputOTP>
104+
<div className="min-h-[32px] px-4 pb-3 pt-1">
105+
{errorId ? <ErrorList id={errorId} errors={errors} /> : null}
106+
</div>
107+
</div>
108+
)
109+
}
110+
60111
export function TextareaField({
61112
labelProps,
62113
textareaProps,

app/components/ui/input-otp.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { OTPInput, OTPInputContext } from 'input-otp'
2+
import * as React from 'react'
3+
4+
import { cn } from '#app/utils/misc.tsx'
5+
6+
const InputOTP = React.forwardRef<
7+
React.ElementRef<typeof OTPInput>,
8+
React.ComponentPropsWithoutRef<typeof OTPInput>
9+
>(({ className, containerClassName, ...props }, ref) => (
10+
<OTPInput
11+
ref={ref}
12+
containerClassName={cn(
13+
'flex items-center gap-2 has-[:disabled]:opacity-50',
14+
containerClassName,
15+
)}
16+
className={cn('disabled:cursor-not-allowed', className)}
17+
{...props}
18+
/>
19+
))
20+
InputOTP.displayName = 'InputOTP'
21+
22+
const InputOTPGroup = React.forwardRef<
23+
React.ElementRef<'div'>,
24+
React.ComponentPropsWithoutRef<'div'>
25+
>(({ className, ...props }, ref) => (
26+
<div ref={ref} className={cn('flex items-center', className)} {...props} />
27+
))
28+
InputOTPGroup.displayName = 'InputOTPGroup'
29+
30+
const InputOTPSlot = React.forwardRef<
31+
React.ElementRef<'div'>,
32+
React.ComponentPropsWithoutRef<'div'> & { index: number }
33+
>(({ index, className, ...props }, ref) => {
34+
const inputOTPContext = React.useContext(OTPInputContext)
35+
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
36+
37+
return (
38+
<div
39+
ref={ref}
40+
className={cn(
41+
'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
42+
isActive && 'z-10 ring-2 ring-ring ring-offset-background',
43+
className,
44+
)}
45+
{...props}
46+
>
47+
{char}
48+
{hasFakeCaret && (
49+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
50+
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
51+
</div>
52+
)}
53+
</div>
54+
)
55+
})
56+
InputOTPSlot.displayName = 'InputOTPSlot'
57+
58+
const InputOTPSeparator = React.forwardRef<
59+
React.ElementRef<'div'>,
60+
React.ComponentPropsWithoutRef<'div'>
61+
>(({ ...props }, ref) => (
62+
<div ref={ref} role="separator" {...props}>
63+
-
64+
</div>
65+
))
66+
InputOTPSeparator.displayName = 'InputOTPSeparator'
67+
68+
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

app/routes/_auth+/verify.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Form, useActionData, useSearchParams } from '@remix-run/react'
55
import { HoneypotInputs } from 'remix-utils/honeypot/react'
66
import { z } from 'zod'
77
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
8-
import { ErrorList, Field } from '#app/components/forms.tsx'
8+
import { ErrorList, OTPField } from '#app/components/forms.tsx'
99
import { Spacer } from '#app/components/spacer.tsx'
1010
import { StatusButton } from '#app/components/ui/status-button.tsx'
1111
import { checkHoneypot } from '#app/utils/honeypot.server.ts'
@@ -95,17 +95,19 @@ export default function VerifyRoute() {
9595
<div className="flex w-full gap-2">
9696
<Form method="POST" {...getFormProps(form)} className="flex-1">
9797
<HoneypotInputs />
98-
<Field
99-
labelProps={{
100-
htmlFor: fields[codeQueryParam].id,
101-
children: 'Code',
102-
}}
103-
inputProps={{
104-
...getInputProps(fields[codeQueryParam], { type: 'text' }),
105-
autoComplete: 'one-time-code',
106-
}}
107-
errors={fields[codeQueryParam].errors}
108-
/>
98+
<div className="flex items-center justify-center">
99+
<OTPField
100+
labelProps={{
101+
htmlFor: fields[codeQueryParam].id,
102+
children: 'Code',
103+
}}
104+
inputProps={{
105+
...getInputProps(fields[codeQueryParam], { type: 'text' }),
106+
autoComplete: 'one-time-code',
107+
}}
108+
errors={fields[codeQueryParam].errors}
109+
/>
110+
</div>
109111
<input
110112
{...getInputProps(fields[typeQueryParam], { type: 'hidden' })}
111113
/>

app/routes/settings+/profile.two-factor.verify.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from '@remix-run/react'
1616
import * as QRCode from 'qrcode'
1717
import { z } from 'zod'
18-
import { ErrorList, Field } from '#app/components/forms.tsx'
18+
import { ErrorList, OTPField } from '#app/components/forms.tsx'
1919
import { Icon } from '#app/components/ui/icon.tsx'
2020
import { StatusButton } from '#app/components/ui/status-button.tsx'
2121
import { isCodeValid } from '#app/routes/_auth+/verify.server.ts'
@@ -175,18 +175,20 @@ export default function TwoFactorRoute() {
175175
</p>
176176
<div className="flex w-full max-w-xs flex-col justify-center gap-4">
177177
<Form method="POST" {...getFormProps(form)} className="flex-1">
178-
<Field
179-
labelProps={{
180-
htmlFor: fields.code.id,
181-
children: 'Code',
182-
}}
183-
inputProps={{
184-
...getInputProps(fields.code, { type: 'text' }),
185-
autoFocus: true,
186-
autoComplete: 'one-time-code',
187-
}}
188-
errors={fields.code.errors}
189-
/>
178+
<div className="flex items-center justify-center">
179+
<OTPField
180+
labelProps={{
181+
htmlFor: fields.code.id,
182+
children: 'Code',
183+
}}
184+
inputProps={{
185+
...getInputProps(fields.code, { type: 'text' }),
186+
autoFocus: true,
187+
autoComplete: 'one-time-code',
188+
}}
189+
errors={fields.code.errors}
190+
/>
191+
</div>
190192

191193
<div className="min-h-[32px] px-4 pb-3 pt-1">
192194
<ErrorList id={form.errorId} errors={form.errors} />

app/utils/extended-theme.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,13 @@ export const extendedTheme = {
9090
/** 12px size / 16px high / bold */
9191
button: ['0.75rem', { lineHeight: '1rem', fontWeight: '700' }],
9292
},
93+
keyframes: {
94+
'caret-blink': {
95+
'0%,70%,100%': { opacity: '1' },
96+
'20%,50%': { opacity: '0' },
97+
},
98+
},
99+
animation: {
100+
'caret-blink': 'caret-blink 1.25s ease-out infinite',
101+
},
93102
} satisfies Config['theme']

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"get-port": "^7.1.0",
8484
"glob": "^10.3.12",
8585
"helmet": "^7.1.0",
86+
"input-otp": "^1.2.4",
8687
"intl-parse-accept-language": "^1.0.0",
8788
"isbot": "^5.1.6",
8889
"litefs-js": "^1.1.2",

0 commit comments

Comments
 (0)