Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions components/form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -313,6 +314,57 @@ function Switch<T extends FieldValues>({
}
Form.Switch = Switch;

interface OTPProps<T extends FieldValues>
extends Omit<BaseInputProps<T>, "type"> {
maxLength?: number;
children?: React.ReactNode;
slotClassName?: string;
groupClassName?: string;
}

function OTP<T extends FieldValues>({
name,
label,
className = "",
maxLength = 6,
children,
slotClassName = "",
groupClassName = "",
...rest
}: OTPProps<T>) {
const { control } = useFormContext();

return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className={cn("w-full", className)}>
{label && <FormLabel>{label}</FormLabel>}
<FormControl>
<InputOTP
maxLength={maxLength}
value={String(field.value || "")}
onChange={(value: string) => field.onChange(value)}
{...(rest as any)}
>
{children || (
<InputOTPGroup className={groupClassName}>
{Array.from({ length: maxLength }, (_, i) => (
<InputOTPSlot className={slotClassName} key={i} index={i} />
))}
</InputOTPGroup>
)}
</InputOTP>
</FormControl>
<FormMessage>&nbsp;</FormMessage>
</FormItem>
)}
/>
);
}
Form.OTP = OTP;

interface SubmitLabelMapping {
save: string;
saving: string;
Expand Down
77 changes: 77 additions & 0 deletions components/shadcn/input-otp.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}

function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center gap-3", className)}
{...props}
/>
);
}

function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};

return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"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}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4/10 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
}

function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" {...props}>
<MinusIcon />
</div>
);
}

export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
91 changes: 91 additions & 0 deletions stories/forms/OTP.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div className="w-md">
<Story />
</div>
),
],
} satisfies Meta<typeof Form.OTP>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
decorators: [
(Story) => {
const form = useForm({ defaultValues: { default: "" } });
return (
<Form form={form} onSubmit={() => console.log("submitted")}>
<Story />
</Form>
);
},
],

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 (
<Form form={form} onSubmit={() => console.log("submitted")}>
<Story />
</Form>
);
},
],

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 (
<Form
form={form}
onSubmit={() => console.log("submitted")}
className="w-full"
>
<Story />
<pre>result: {JSON.stringify(form.watch("otp"))}</pre>
</Form>
);
},
],

args: {
name: "otp",
label: "OTP with default value",
className: "w-full",
slotClassName: "h-14 w-14",
maxLength: 6,
},
};
11 changes: 11 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down