Skip to content

Commit 97fb516

Browse files
authored
OTP input (#157)
* otp input * otp setup
1 parent b7c4ea8 commit 97fb516

5 files changed

Lines changed: 232 additions & 0 deletions

File tree

components/form/index.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
Form as ShadForm,
2323
} from "../shadcn/form.js";
2424
import { Input, type InputProps } from "../shadcn/input.js";
25+
import { InputOTP, InputOTPGroup, InputOTPSlot } from "../shadcn/input-otp.js";
2526
import {
2627
Select,
2728
SelectContent,
@@ -313,6 +314,57 @@ function Switch<T extends FieldValues>({
313314
}
314315
Form.Switch = Switch;
315316

317+
interface OTPProps<T extends FieldValues>
318+
extends Omit<BaseInputProps<T>, "type"> {
319+
maxLength?: number;
320+
children?: React.ReactNode;
321+
slotClassName?: string;
322+
groupClassName?: string;
323+
}
324+
325+
function OTP<T extends FieldValues>({
326+
name,
327+
label,
328+
className = "",
329+
maxLength = 6,
330+
children,
331+
slotClassName = "",
332+
groupClassName = "",
333+
...rest
334+
}: OTPProps<T>) {
335+
const { control } = useFormContext();
336+
337+
return (
338+
<FormField
339+
control={control}
340+
name={name}
341+
render={({ field }) => (
342+
<FormItem className={cn("w-full", className)}>
343+
{label && <FormLabel>{label}</FormLabel>}
344+
<FormControl>
345+
<InputOTP
346+
maxLength={maxLength}
347+
value={String(field.value || "")}
348+
onChange={(value: string) => field.onChange(value)}
349+
{...(rest as any)}
350+
>
351+
{children || (
352+
<InputOTPGroup className={groupClassName}>
353+
{Array.from({ length: maxLength }, (_, i) => (
354+
<InputOTPSlot className={slotClassName} key={i} index={i} />
355+
))}
356+
</InputOTPGroup>
357+
)}
358+
</InputOTP>
359+
</FormControl>
360+
<FormMessage>&nbsp;</FormMessage>
361+
</FormItem>
362+
)}
363+
/>
364+
);
365+
}
366+
Form.OTP = OTP;
367+
316368
interface SubmitLabelMapping {
317369
save: string;
318370
saving: string;

components/shadcn/input-otp.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"use client";
2+
3+
import { OTPInput, OTPInputContext } from "input-otp";
4+
import { MinusIcon } from "lucide-react";
5+
import * as React from "react";
6+
7+
import { cn } from "../../lib/utils.js";
8+
9+
function InputOTP({
10+
className,
11+
containerClassName,
12+
...props
13+
}: React.ComponentProps<typeof OTPInput> & {
14+
containerClassName?: string;
15+
}) {
16+
return (
17+
<OTPInput
18+
data-slot="input-otp"
19+
containerClassName={cn(
20+
"flex items-center gap-2 has-disabled:opacity-50",
21+
containerClassName,
22+
)}
23+
className={cn("disabled:cursor-not-allowed", className)}
24+
{...props}
25+
/>
26+
);
27+
}
28+
29+
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
30+
return (
31+
<div
32+
data-slot="input-otp-group"
33+
className={cn("flex items-center gap-3", className)}
34+
{...props}
35+
/>
36+
);
37+
}
38+
39+
function InputOTPSlot({
40+
index,
41+
className,
42+
...props
43+
}: React.ComponentProps<"div"> & {
44+
index: number;
45+
}) {
46+
const inputOTPContext = React.useContext(OTPInputContext);
47+
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
48+
49+
return (
50+
<div
51+
data-slot="input-otp-slot"
52+
data-active={isActive}
53+
className={cn(
54+
"relative flex h-12 w-12 items-center justify-center rounded-lg border-2 border-border bg-background font-mono text-2xl outline-none transition-all disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-primary data-[active=true]:ring-2 data-[active=true]:ring-primary/20 data-[active=true]:aria-invalid:border-destructive",
55+
className,
56+
)}
57+
{...props}
58+
>
59+
{char}
60+
{hasFakeCaret && (
61+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
62+
<div className="h-4/10 w-px animate-caret-blink bg-foreground duration-1000" />
63+
</div>
64+
)}
65+
</div>
66+
);
67+
}
68+
69+
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
70+
return (
71+
<div data-slot="input-otp-separator" {...props}>
72+
<MinusIcon />
73+
</div>
74+
);
75+
}
76+
77+
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"class-variance-authority": "^0.7.1",
4545
"clsx": "^2.0",
4646
"cmdk": "1.1.1",
47+
"input-otp": "^1.4.2",
4748
"lucide-react": "^0.506.0",
4849
"tailwind-merge": "^3.3.0",
4950
"tailwindcss-animate": "^1.0.7"

stories/forms/OTP.stories.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import React from "react";
3+
import { useForm } from "react-hook-form";
4+
import { Form } from "../../components/form/index.js";
5+
6+
const meta = {
7+
title: "Components/Form/OTP",
8+
component: Form.OTP,
9+
parameters: {
10+
layout: "centered",
11+
},
12+
tags: ["autodocs"],
13+
decorators: [
14+
(Story) => (
15+
<div className="w-md">
16+
<Story />
17+
</div>
18+
),
19+
],
20+
} satisfies Meta<typeof Form.OTP>;
21+
22+
export default meta;
23+
type Story = StoryObj<typeof meta>;
24+
25+
export const Default: Story = {
26+
decorators: [
27+
(Story) => {
28+
const form = useForm({ defaultValues: { default: "" } });
29+
return (
30+
<Form form={form} onSubmit={() => console.log("submitted")}>
31+
<Story />
32+
</Form>
33+
);
34+
},
35+
],
36+
37+
args: {
38+
name: "default",
39+
label: "Default OTP input",
40+
className: "w-full",
41+
maxLength: 6,
42+
slotClassName: "h-14 w-14 text-2xl",
43+
},
44+
};
45+
46+
export const CustomLength: Story = {
47+
decorators: [
48+
(Story) => {
49+
const form = useForm({ defaultValues: { otp: "" } });
50+
return (
51+
<Form form={form} onSubmit={() => console.log("submitted")}>
52+
<Story />
53+
</Form>
54+
);
55+
},
56+
],
57+
58+
args: {
59+
name: "otp",
60+
label: "4-digit OTP",
61+
className: "w-full",
62+
slotClassName: "h-14 w-14",
63+
maxLength: 4,
64+
},
65+
};
66+
67+
export const WithDefaultValue: Story = {
68+
decorators: [
69+
(Story) => {
70+
const form = useForm({ defaultValues: { otp: "123456" } });
71+
return (
72+
<Form
73+
form={form}
74+
onSubmit={() => console.log("submitted")}
75+
className="w-full"
76+
>
77+
<Story />
78+
<pre>result: {JSON.stringify(form.watch("otp"))}</pre>
79+
</Form>
80+
);
81+
},
82+
],
83+
84+
args: {
85+
name: "otp",
86+
label: "OTP with default value",
87+
className: "w-full",
88+
slotClassName: "h-14 w-14",
89+
maxLength: 6,
90+
},
91+
};

yarn.lock

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,7 @@ __metadata:
611611
class-variance-authority: "npm:^0.7.1"
612612
clsx: "npm:^2.1.1"
613613
cmdk: "npm:1.1.1"
614+
input-otp: "npm:^1.4.2"
614615
lucide-react: "npm:^0.506.0"
615616
next-themes: "npm:^0.4.6"
616617
postcss: "npm:^8.5.3"
@@ -3828,6 +3829,16 @@ __metadata:
38283829
languageName: node
38293830
linkType: hard
38303831

3832+
"input-otp@npm:^1.4.2":
3833+
version: 1.4.2
3834+
resolution: "input-otp@npm:1.4.2"
3835+
peerDependencies:
3836+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
3837+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
3838+
checksum: 10c0/d3a3216a75ed832993f3f2852edd7a85c5bae30ea6d251182119120488bbf9fed7cfdd91819bcee6daff57b3cfcbca94fd16d6a7c92cee4d806c0d4fa6ff1128
3839+
languageName: node
3840+
linkType: hard
3841+
38313842
"ip-address@npm:^9.0.5":
38323843
version: 9.0.5
38333844
resolution: "ip-address@npm:9.0.5"

0 commit comments

Comments
 (0)