From 1122bab49d53c89ce57d062857acb2f5492becda Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Sat, 13 Dec 2025 16:58:28 +0000 Subject: [PATCH 1/2] otp input --- components/form/index.tsx | 52 ++++++++++++++++++ components/shadcn/input-otp.tsx | 77 +++++++++++++++++++++++++++ package.json | 1 + stories/forms/OTP.stories.tsx | 93 +++++++++++++++++++++++++++++++++ yarn.lock | 11 ++++ 5 files changed, 234 insertions(+) create mode 100644 components/shadcn/input-otp.tsx create mode 100644 stories/forms/OTP.stories.tsx diff --git a/components/form/index.tsx b/components/form/index.tsx index 5590848..70ff638 100644 --- a/components/form/index.tsx +++ b/components/form/index.tsx @@ -31,6 +31,7 @@ import { } from "../shadcn/select.js"; import { Switch as ShadSwitch } from "../shadcn/switch.js"; import { Textarea as ShadTextarea } from "../shadcn/textarea.js"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "../shadcn/input-otp.js"; interface Props extends Omit, "onSubmit"> { @@ -313,6 +314,57 @@ function Switch({ } Form.Switch = Switch; +interface OTPProps + extends Omit, "type"> { + maxLength?: number; + children?: React.ReactNode; + slotClassName?: string; + groupClassName?: string; +} + +function OTP({ + name, + label, + className = "", + maxLength = 6, + children, + slotClassName = "", + groupClassName = "", + ...rest +}: OTPProps) { + const { control } = useFormContext(); + + return ( + ( + + {label && {label}} + + field.onChange(value)} + {...(rest as any)} + > + {children || ( + + {Array.from({ length: maxLength }, (_, i) => ( + + ))} + + )} + + +   + + )} + /> + ); +} +Form.OTP = OTP; + interface SubmitLabelMapping { save: string; saving: string; diff --git a/components/shadcn/input-otp.tsx b/components/shadcn/input-otp.tsx new file mode 100644 index 0000000..f709930 --- /dev/null +++ b/components/shadcn/input-otp.tsx @@ -0,0 +1,77 @@ +"use client"; + +import * as React from "react"; +import { OTPInput, OTPInputContext } from "input-otp"; +import { MinusIcon } from "lucide-react"; + +import { cn } from "../../lib/utils.js"; + +function InputOTP({ + className, + containerClassName, + ...props +}: React.ComponentProps & { + containerClassName?: string; +}) { + return ( + + ); +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function InputOTPSlot({ + index, + className, + ...props +}: React.ComponentProps<"div"> & { + index: number; +}) { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( +
+ +
+ ); +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/package.json b/package.json index 5430009..356b7bc 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.0", "cmdk": "1.1.1", + "input-otp": "^1.4.2", "lucide-react": "^0.506.0", "tailwind-merge": "^3.3.0", "tailwindcss-animate": "^1.0.7" diff --git a/stories/forms/OTP.stories.tsx b/stories/forms/OTP.stories.tsx new file mode 100644 index 0000000..2e0262d --- /dev/null +++ b/stories/forms/OTP.stories.tsx @@ -0,0 +1,93 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import React, { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Form } from "../../components/form/index.js"; + +const meta = { + title: "Components/Form/OTP", + component: Form.OTP, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + (Story) => { + const form = useForm({ defaultValues: { default: "" } }); + return ( +
console.log("submitted")}> + + + ); + }, + ], + + args: { + name: "default", + label: "Default OTP input", + className: "w-full", + maxLength: 6, + slotClassName: "h-14 w-14 text-2xl", + }, +}; + +export const CustomLength: Story = { + decorators: [ + (Story) => { + const form = useForm({ defaultValues: { otp: "" } }); + return ( +
console.log("submitted")}> + + + ); + }, + ], + + args: { + name: "otp", + label: "4-digit OTP", + className: "w-full", + slotClassName: "h-14 w-14", + maxLength: 4, + }, +}; + +export const WithDefaultValue: Story = { + decorators: [ + (Story) => { + const form = useForm({ defaultValues: { otp: "123456" } }); + return ( +
console.log("submitted")} + className="w-full" + > + +
result: {JSON.stringify(form.watch("otp"))}
+ + ); + }, + ], + + args: { + name: "otp", + label: "OTP with default value", + className: "w-full", + slotClassName: "h-14 w-14", + maxLength: 6, + }, +}; diff --git a/yarn.lock b/yarn.lock index 122f1ef..a8272b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -611,6 +611,7 @@ __metadata: class-variance-authority: "npm:^0.7.1" clsx: "npm:^2.1.1" cmdk: "npm:1.1.1" + input-otp: "npm:^1.4.2" lucide-react: "npm:^0.506.0" next-themes: "npm:^0.4.6" postcss: "npm:^8.5.3" @@ -3828,6 +3829,16 @@ __metadata: languageName: node linkType: hard +"input-otp@npm:^1.4.2": + version: 1.4.2 + resolution: "input-otp@npm:1.4.2" + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + checksum: 10c0/d3a3216a75ed832993f3f2852edd7a85c5bae30ea6d251182119120488bbf9fed7cfdd91819bcee6daff57b3cfcbca94fd16d6a7c92cee4d806c0d4fa6ff1128 + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" From ccddbf2aeefbfcabffff9a3c9b5cee51dcfded9d Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Mon, 15 Dec 2025 16:24:34 +0000 Subject: [PATCH 2/2] otp setup --- components/form/index.tsx | 2 +- components/shadcn/input-otp.tsx | 10 +++++----- stories/forms/OTP.stories.tsx | 4 +--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/components/form/index.tsx b/components/form/index.tsx index 70ff638..fbfc9e7 100644 --- a/components/form/index.tsx +++ b/components/form/index.tsx @@ -22,6 +22,7 @@ import { Form as ShadForm, } from "../shadcn/form.js"; import { Input, type InputProps } from "../shadcn/input.js"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "../shadcn/input-otp.js"; import { Select, SelectContent, @@ -31,7 +32,6 @@ import { } from "../shadcn/select.js"; import { Switch as ShadSwitch } from "../shadcn/switch.js"; import { Textarea as ShadTextarea } from "../shadcn/textarea.js"; -import { InputOTP, InputOTPGroup, InputOTPSlot } from "../shadcn/input-otp.js"; interface Props extends Omit, "onSubmit"> { diff --git a/components/shadcn/input-otp.tsx b/components/shadcn/input-otp.tsx index f709930..aeec8c0 100644 --- a/components/shadcn/input-otp.tsx +++ b/components/shadcn/input-otp.tsx @@ -1,8 +1,8 @@ "use client"; -import * as React from "react"; import { OTPInput, OTPInputContext } from "input-otp"; import { MinusIcon } from "lucide-react"; +import * as React from "react"; import { cn } from "../../lib/utils.js"; @@ -30,7 +30,7 @@ function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { return (
); @@ -51,7 +51,7 @@ function InputOTPSlot({ data-slot="input-otp-slot" data-active={isActive} className={cn( - "border-input bg-background relative flex h-9 w-9 items-center justify-center border-y border-r text-sm transition-colors outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-1 data-[active=true]:ring-ring/50 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 disabled:cursor-not-allowed disabled:opacity-50", + "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", className, )} {...props} @@ -59,7 +59,7 @@ function InputOTPSlot({ {char} {hasFakeCaret && (
-
+
)}
@@ -68,7 +68,7 @@ function InputOTPSlot({ function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { return ( -
+
); diff --git a/stories/forms/OTP.stories.tsx b/stories/forms/OTP.stories.tsx index 2e0262d..adf899a 100644 --- a/stories/forms/OTP.stories.tsx +++ b/stories/forms/OTP.stories.tsx @@ -1,8 +1,6 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import React, { useEffect } from "react"; +import React from "react"; import { useForm } from "react-hook-form"; -import { z } from "zod"; import { Form } from "../../components/form/index.js"; const meta = {