diff --git a/components/form/index.tsx b/components/form/index.tsx index 5590848..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, @@ -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..aeec8c0 --- /dev/null +++ b/components/shadcn/input-otp.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { OTPInput, OTPInputContext } from "input-otp"; +import { MinusIcon } from "lucide-react"; +import * as React from "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..adf899a --- /dev/null +++ b/stories/forms/OTP.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import React from "react"; +import { useForm } from "react-hook-form"; +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"