From a40f503bdbf38a164d05ac488ee2f838804bf888 Mon Sep 17 00:00:00 2001 From: Damian Ricobelli Date: Tue, 5 Mar 2024 16:49:19 -0300 Subject: [PATCH] chore: fix usability issues and API improvements --- apps/www/__registry__/index.tsx | 14 +++ .../www/content/docs/components/otp-input.mdx | 71 ++++++++++- .../registry/styles/default/otp-input.json | 2 +- .../public/registry/styles/default/toast.json | 4 +- .../registry/styles/new-york/otp-input.json | 2 +- .../registry/styles/new-york/toast.json | 4 +- .../default/example/otp-input-controlled.tsx | 11 +- .../example/otp-input-custom-number.tsx | 4 +- .../example/otp-input-custom-style.tsx | 32 +++++ .../default/example/otp-input-demo.tsx | 4 +- .../default/example/otp-input-form.tsx | 2 +- .../default/example/otp-input-on-paste.tsx | 8 +- .../default/example/otp-input-placeholder.tsx | 4 +- .../default/example/otp-input-separator.tsx | 4 +- apps/www/registry/default/ui/otp-input.tsx | 116 +++++++++++++----- .../new-york/example/otp-input-controlled.tsx | 11 +- .../example/otp-input-custom-number.tsx | 4 +- .../example/otp-input-custom-style.tsx | 33 +++++ .../new-york/example/otp-input-demo.tsx | 4 +- .../new-york/example/otp-input-form.tsx | 2 +- .../new-york/example/otp-input-on-paste.tsx | 8 +- .../example/otp-input-placeholder.tsx | 4 +- .../new-york/example/otp-input-separator.tsx | 4 +- apps/www/registry/new-york/ui/otp-input.tsx | 114 ++++++++++++----- apps/www/registry/registry.ts | 6 + 25 files changed, 362 insertions(+), 110 deletions(-) create mode 100644 apps/www/registry/default/example/otp-input-custom-style.tsx create mode 100644 apps/www/registry/new-york/example/otp-input-custom-style.tsx diff --git a/apps/www/__registry__/index.tsx b/apps/www/__registry__/index.tsx index a4c7b4bfc68..46ee4b05b18 100644 --- a/apps/www/__registry__/index.tsx +++ b/apps/www/__registry__/index.tsx @@ -831,6 +831,13 @@ export const Index: Record = { component: React.lazy(() => import("@/registry/default/example/otp-input-form")), files: ["registry/default/example/otp-input-form.tsx"], }, + "otp-input-custom-style": { + name: "otp-input-custom-style", + type: "components:example", + registryDependencies: ["otp-input"], + component: React.lazy(() => import("@/registry/default/example/otp-input-custom-style")), + files: ["registry/default/example/otp-input-custom-style.tsx"], + }, "pagination-demo": { name: "pagination-demo", type: "components:example", @@ -2113,6 +2120,13 @@ export const Index: Record = { component: React.lazy(() => import("@/registry/new-york/example/otp-input-form")), files: ["registry/new-york/example/otp-input-form.tsx"], }, + "otp-input-custom-style": { + name: "otp-input-custom-style", + type: "components:example", + registryDependencies: ["otp-input"], + component: React.lazy(() => import("@/registry/new-york/example/otp-input-custom-style")), + files: ["registry/new-york/example/otp-input-custom-style.tsx"], + }, "pagination-demo": { name: "pagination-demo", type: "components:example", diff --git a/apps/www/content/docs/components/otp-input.mdx b/apps/www/content/docs/components/otp-input.mdx index afb436f9e10..dd2cdf4a8ad 100644 --- a/apps/www/content/docs/components/otp-input.mdx +++ b/apps/www/content/docs/components/otp-input.mdx @@ -8,7 +8,7 @@ component: true ## About -OTP Input is built on top of the [react-otp-input](https://www.npmjs.com/package/react-otp-input) API and has been modified to suit the styles and needs of Shadcn. For more information on the original package, please refer to the [documentation](https://github.com/devfolioco/react-otp-input). +OTP Input is inspired by the [react-otp-input](https://www.npmjs.com/package/react-otp-input) and [input-otp](https://input-otp.rodz.dev/) libraries to build a simple, accessible and customizable component by composing Shadcn base components. ## Installation @@ -45,7 +45,7 @@ npx shadcn-ui@latest add otp-input ## Usage ```tsx -import { OTPInput } from '@/components/ui/otp-input'; +import { OTPInput } from "@/components/ui/otp-input" ``` ```tsx @@ -76,6 +76,11 @@ Sometimes you may need to have a separator between each generated input. For thi + + The `renderSeparator` prop takes the index of the current input as an optional + argument. This can be useful if you need a separator in a certain place. + + ### Controlling the value You can control the value of the OTP input by passing the `value` and `onChange` props. @@ -93,11 +98,16 @@ You can detect the `onPaste` event by passing the `onPaste` prop. You can set the `autoFocus` prop to `true` to automatically focus the first input. -For practical purposes, we did not generate a live example for this case to avoid scrolling the page to this point because of the `autoFocus` + For practical purposes, we did not generate a live example for this case to + avoid scrolling the page to this point because of the `autoFocus` +#### Last Input Focused + +You can set the `lastInputFocused` prop to `true` to automatically focus the last input. This can be useful if by default the OTP value is already complete and we want the component to have the focus on the last input so that the user can easily modify the value. + ```tsx -import { OTPInput } from "@/components/ui/otp-input"; +import { OTPInput } from "@/components/ui/otp-input" export default function OtpInputDemo() { return @@ -118,4 +128,55 @@ The component renders by default this line of code ``` -This allows to have a hidden input for the form you use with server actions and you can capture it inside the FormData. It is important that you set the `name` prop to be included inside the FormData. \ No newline at end of file +This allows to have a hidden input for the form you use with server actions and you can capture it inside the FormData. It is important that you set the `name` prop to be included inside the FormData. + +### Custom styles + +You can customize the styles of the OTP input by passing the `styles` prop. The `styles` prop is an object that accepts the following keys: + +- `container` - The container of the OTP input. +- `input` - The input of the OTP input. It can be a string or a function that accepts the index of the input. + + + +## API + +```tsx +type AllowedInputTypes = "password" | "text" | "number" | "tel" + +interface OTPInputProps + extends Pick< + React.InputHTMLAttributes, + "pattern" | "autoFocus" | "id" | "name" + > { + /** Value of the OTP input */ + value?: string + /** Callback to be called when the OTP value changes */ + onChange?: (otp: string) => void + /** Callback to be called when pasting content into the component */ + onPaste?: (event: React.ClipboardEvent) => void + /** Callback to be called when the input is focused */ + onFocus?: (event: React.FocusEvent) => void + /** Callback to be called when the input is blurred */ + onBlur?: (event: React.FocusEvent) => void + /** Callback to be called when a key is pressed */ + onKeyDown?: (event: React.KeyboardEvent) => void + /** Callback to be called when the input value changes */ + onInput?: (event: React.FormEvent) => void + /** Number of OTP inputs to be rendered */ + numInputs?: number + /** Placeholder for the inputs */ + placeholder?: string + /** Type of the input */ + type?: AllowedInputTypes + /** Function to render the separator */ + renderSeparator?: ((index: number) => React.ReactNode) | React.ReactNode + /** Additional styles for the component */ + styles?: { + container?: string + input?: string | ((index: number) => React.ReactNode) + } + /** Focus the last input */ + lastInputFocused?: boolean +} +``` diff --git a/apps/www/public/registry/styles/default/otp-input.json b/apps/www/public/registry/styles/default/otp-input.json index fe349632b88..e486fbbc08e 100644 --- a/apps/www/public/registry/styles/default/otp-input.json +++ b/apps/www/public/registry/styles/default/otp-input.json @@ -3,7 +3,7 @@ "files": [ { "name": "otp-input.tsx", - "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Input } from \"@/registry/default/ui/input\";\n\ntype AllowedInputTypes = 'password' | 'text' | 'number' | 'tel';\n\ninterface OTPInputProps\n extends Pick<\n React.InputHTMLAttributes,\n | \"onPaste\"\n | \"pattern\"\n | \"autoFocus\"\n | \"className\"\n | \"id\"\n | \"name\"\n > {\n /** Value of the OTP input */\n value?: string\n /** Callback to be called when the OTP value changes */\n onChange?: (otp: string) => void\n /** Callback to be called when pasting content into the component */\n onPaste?: (event: React.ClipboardEvent) => void;\n /** Number of OTP inputs to be rendered */\n numInputs?: number\n /** Placeholder for the inputs */\n placeholder?: string;\n /** Type of the input */\n type?: AllowedInputTypes\n /** Function to render the separator */\n renderSeparator?: ((index: number) => React.ReactNode) | React.ReactNode;\n}\n\nexport const OTPInput = ({\n value = \"\",\n numInputs = 6,\n onChange,\n type = \"text\",\n placeholder = \"_\",\n pattern = \"[0-9]\",\n autoFocus = true,\n className,\n id,\n name,\n onPaste,\n renderSeparator,\n ...rest\n}: OTPInputProps) => {\n const [otpValue, setOTPValue] = React.useState(value)\n const [activeInput, setActiveInput] = React.useState(0)\n const inputRefs = React.useRef>([])\n\n const getOTPValue = () => (otpValue ? otpValue.toString().split(\"\") : [])\n\n const isInputNum = type === \"number\" || type === \"tel\"\n\n React.useEffect(() => {\n inputRefs.current = inputRefs.current.slice(0, numInputs)\n }, [numInputs])\n\n React.useEffect(() => {\n if (autoFocus) {\n inputRefs.current[0]?.focus()\n }\n }, [autoFocus])\n\n const isInputValueValid = (value: string) => {\n const isTypeValid = isInputNum\n ? !isNaN(Number(value))\n : typeof value === \"string\"\n return isTypeValid && value.trim().length === 1\n }\n\n const handleChange = (event: React.ChangeEvent) => {\n const { value } = event.target\n\n if (isInputValueValid(value)) {\n changeCodeAtFocus(value)\n focusInput(activeInput + 1)\n }\n }\n\n const handleFocus =\n (event: React.FocusEvent) => (index: number) => {\n setActiveInput(index)\n event.target.select()\n }\n\n const handleBlur = () => {\n setActiveInput(activeInput - 1)\n }\n\n const handleKeyDown = (event: React.KeyboardEvent) => {\n const otp = getOTPValue()\n if ([event.code, event.key].includes(\"Backspace\")) {\n event.preventDefault()\n changeCodeAtFocus(\"\")\n focusInput(activeInput - 1)\n } else if (event.code === \"Delete\") {\n event.preventDefault()\n changeCodeAtFocus(\"\")\n } else if (event.code === \"ArrowLeft\") {\n event.preventDefault()\n focusInput(activeInput - 1)\n } else if (event.code === \"ArrowRight\") {\n event.preventDefault()\n focusInput(activeInput + 1)\n }\n // React does not trigger onChange when the same value is entered\n // again. So we need to focus the next input manually in this case.\n else if (event.key === otp[activeInput]) {\n event.preventDefault()\n focusInput(activeInput + 1)\n } else if (\n event.code === \"Spacebar\" ||\n event.code === \"Space\" ||\n event.code === \"ArrowUp\" ||\n event.code === \"ArrowDown\"\n ) {\n event.preventDefault()\n } else if (isInputNum && !isInputValueValid(event.key)) {\n event.preventDefault()\n }\n }\n\n const focusInput = (index: number) => {\n const activeInput = Math.max(Math.min(numInputs - 1, index), 0)\n\n if (inputRefs.current[activeInput]) {\n inputRefs.current[activeInput]?.focus()\n inputRefs.current[activeInput]?.select()\n setActiveInput(activeInput)\n }\n }\n\n const changeCodeAtFocus = (value: string) => {\n const otp = getOTPValue()\n otp[activeInput] = value[0]\n handleOTPChange(otp)\n }\n\n const handleOTPChange = (otp: Array) => {\n const otpValue = otp.join(\"\")\n setOTPValue(otpValue)\n onChange?.(otpValue)\n }\n\n const handlePaste = (event: React.ClipboardEvent) => {\n event.preventDefault()\n\n const otp = getOTPValue()\n let nextActiveInput = activeInput\n\n // Get pastedData in an array of max size (num of inputs - current position)\n const pastedData = event.clipboardData\n .getData(\"text/plain\")\n .slice(0, numInputs - activeInput)\n .split(\"\")\n\n // Prevent pasting if the clipboard data contains non-numeric values for number inputs\n if (isInputNum && pastedData.some((value) => isNaN(Number(value)))) {\n return\n }\n\n // Paste data from focused input onwards\n for (let pos = 0; pos < numInputs; ++pos) {\n if (pos >= activeInput && pastedData.length > 0) {\n otp[pos] = pastedData.shift() ?? \"\"\n nextActiveInput++\n }\n }\n\n focusInput(nextActiveInput)\n handleOTPChange(otp)\n }\n\n return (\n
\n {Array.from({ length: numInputs }, (_, index) => index).map((i) => (\n \n (inputRefs.current[i] = element)}\n onChange={handleChange}\n onFocus={(event) => handleFocus(event)(i)}\n onBlur={handleBlur}\n onKeyDown={handleKeyDown}\n onPaste={handlePaste}\n autoComplete=\"off\"\n maxLength={1}\n size={1}\n className={cn(\"text-center font-bold\", className)}\n pattern={pattern}\n {...rest}\n />\n {i < numInputs - 1 && (typeof renderSeparator === 'function' ? renderSeparator(i) : renderSeparator)}\n \n ))}\n \n
\n )\n}\n" + "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Input } from \"@/registry/default/ui/input\";\n\ntype AllowedInputTypes = 'password' | 'text' | 'number' | 'tel';\n\ninterface OTPInputProps\n extends Pick<\n React.InputHTMLAttributes,\n | \"pattern\"\n | \"autoFocus\"\n | \"id\"\n | \"name\"\n > {\n /** Value of the OTP input */\n value?: string\n /** Callback to be called when the OTP value changes */\n onChange?: (otp: string) => void\n /** Callback to be called when pasting content into the component */\n onPaste?: (event: React.ClipboardEvent) => void;\n /** Callback to be called when the input is focused */\n onFocus?: (event: React.FocusEvent) => void;\n /** Callback to be called when the input is blurred */\n onBlur?: (event: React.FocusEvent) => void;\n /** Callback to be called when a key is pressed */\n onKeyDown?: (event: React.KeyboardEvent) => void;\n /** Callback to be called when the input value changes */\n onInput?: (event: React.FormEvent) => void;\n /** Number of OTP inputs to be rendered */\n numInputs?: number\n /** Placeholder for the inputs */\n placeholder?: string;\n /** Type of the input */\n type?: AllowedInputTypes\n /** Function to render the separator */\n renderSeparator?: ((index: number) => React.ReactNode) | React.ReactNode;\n /** Additional styles for the component */\n styles?: {\n container?: string;\n input?: string | ((index: number) => React.ReactNode)\n }\n /** Focus the last input */\n lastInputFocused?: boolean;\n}\n\nexport const OTPInput = ({\n value = \"\",\n numInputs = 6,\n onChange,\n onPaste,\n onFocus,\n onBlur,\n onKeyDown,\n onInput,\n type = \"text\",\n placeholder = \"_\",\n pattern = \"[0-9]\",\n autoFocus = false,\n lastInputFocused = false,\n styles,\n id,\n name,\n renderSeparator,\n ...rest\n}: OTPInputProps) => {\n const [otpValue, setOTPValue] = React.useState(\n new Array(numInputs).fill(\"\").map((_, index) => value[index] ?? \"\") \n )\n const [activeInput, setActiveInput] = React.useState(0)\n const inputRefs = React.useRef>([])\n\n const getOTPValue = () => otpValue\n\n const isInputNum = type === \"number\" || type === \"tel\"\n\n React.useEffect(() => {\n inputRefs.current = inputRefs.current.slice(0, numInputs)\n }, [numInputs])\n\n React.useEffect(() => {\n if (autoFocus) {\n inputRefs.current[0]?.focus()\n } else if (lastInputFocused) {\n focusInput(numInputs - 1);\n }\n }, [autoFocus, lastInputFocused])\n\n const isInputValueValid = (value: string) => {\n const isTypeValid = isInputNum\n ? !isNaN(Number(value))\n : typeof value === \"string\"\n return isTypeValid && value.trim().length === 1\n }\n\n const handleChange = (event: React.ChangeEvent) => {\n const { value } = event.target\n\n if (isInputValueValid(value)) {\n changeCodeAtFocus(value)\n focusInput(activeInput + 1)\n }\n }\n\n const handleFocus =\n (event: React.FocusEvent) => (index: number) => {\n const otp = getOTPValue()\n if(otp[index] === \"\") {\n const lastFilledIndex = otp.findIndex((value) => value === \"\")\n setActiveInput(lastFilledIndex)\n inputRefs.current[lastFilledIndex]?.select()\n return\n } else {\n setActiveInput(index)\n event.target.select()\n }\n onFocus?.(event)\n }\n\n const handleBlur = (event: React.FocusEvent) => {\n setActiveInput(activeInput - 1)\n onBlur?.(event)\n }\n\n const handleKeyDown = (event: React.KeyboardEvent) => {\n const otp = getOTPValue()\n if ([event.code, event.key].includes(\"Backspace\")) {\n event.preventDefault()\n changeCodeAtFocus(\"\")\n focusInput(activeInput - 1)\n } else if (event.code === \"Delete\") {\n event.preventDefault()\n changeCodeAtFocus(\"\")\n } else if (event.code === \"ArrowLeft\") {\n event.preventDefault()\n focusInput(activeInput - 1)\n } else if (event.code === \"ArrowRight\") {\n event.preventDefault()\n if (otp[activeInput]) {\n focusInput(activeInput + 1)\n }\n }\n // React does not trigger onChange when the same value is entered\n // again. So we need to focus the next input manually in this case.\n else if (event.key === otp[activeInput]) {\n event.preventDefault()\n focusInput(activeInput + 1)\n } else if (\n event.code === \"Spacebar\" ||\n event.code === \"Space\" ||\n event.code === \"ArrowUp\" ||\n event.code === \"ArrowDown\"\n ) {\n event.preventDefault()\n } else if (isInputNum && !isInputValueValid(event.key)) {\n event.preventDefault()\n }\n onKeyDown?.(event)\n }\n\n const focusInput = (index: number) => {\n const activeInput = Math.max(Math.min(numInputs - 1, index), 0)\n\n if (inputRefs.current[activeInput]) {\n inputRefs.current[activeInput]?.focus()\n inputRefs.current[activeInput]?.select()\n setActiveInput(activeInput)\n }\n }\n\n const changeCodeAtFocus = (value: string) => {\n let otp = getOTPValue()\n if(value === \"\") {\n // If the value is empty, then move the values to the left (e.g Backspace is pressed)\n let newOtp = otp.slice(0, activeInput).concat(otp.slice(activeInput + 1))\n newOtp = newOtp.concat(new Array(numInputs - newOtp.length).fill(\"\"))\n handleOTPChange(newOtp)\n } else {\n otp[activeInput] = value\n handleOTPChange(otp)\n }\n }\n\n const handleOTPChange = (otp: Array) => {\n setOTPValue([...otp, ...new Array(numInputs - otp.length).fill(\"\")])\n onChange?.(otp.join(\"\"))\n }\n\n const handlePaste = (event: React.ClipboardEvent) => {\n event.preventDefault()\n\n const otp = getOTPValue()\n let nextActiveInput = activeInput\n\n // Get pastedData in an array of max size (num of inputs - current position)\n const pastedData = event.clipboardData\n .getData(\"text/plain\")\n .slice(0, numInputs - activeInput)\n .split(\"\")\n\n // Prevent pasting if the clipboard data contains non-numeric values for number inputs\n if (isInputNum && pastedData.some((value) => isNaN(Number(value)))) {\n return\n }\n\n // Paste data from focused input onwards\n for (let pos = 0; pos < numInputs; ++pos) {\n if (pos >= activeInput && pastedData.length > 0) {\n otp[pos] = pastedData.shift() ?? \"\"\n nextActiveInput++\n }\n }\n\n focusInput(nextActiveInput)\n handleOTPChange(otp)\n onPaste?.(event)\n }\n\n return (\n
\n {Array.from({ length: numInputs }, (_, index) => index).map((i) => (\n \n (inputRefs.current[i] = element)}\n onChange={handleChange}\n onFocus={(event) => handleFocus(event)(i)}\n onBlur={handleBlur}\n onKeyDown={handleKeyDown}\n onPaste={handlePaste}\n onInput={onInput}\n autoComplete=\"one-time-code\"\n maxLength={1}\n size={1}\n className={cn(\"text-center font-bold\", typeof styles?.input === \"function\" ? styles.input(i) : styles?.input)}\n pattern={pattern}\n {...rest}\n />\n {typeof renderSeparator === 'function' ? renderSeparator(i) : i < numInputs - 1 && renderSeparator}\n \n ))}\n \n
\n )\n}\n" } ], "type": "components:ui" diff --git a/apps/www/public/registry/styles/default/toast.json b/apps/www/public/registry/styles/default/toast.json index bbd45e06858..398a8985ede 100644 --- a/apps/www/public/registry/styles/default/toast.json +++ b/apps/www/public/registry/styles/default/toast.json @@ -6,11 +6,11 @@ "files": [ { "name": "toast.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\"\nimport * as ToastPrimitives from \"@radix-ui/react-toast\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ToastProvider = ToastPrimitives.Provider\n\nconst ToastViewport = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastViewport.displayName = ToastPrimitives.Viewport.displayName\n\nconst toastVariants = cva(\n \"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full\",\n {\n variants: {\n variant: {\n default: \"border bg-background text-foreground\",\n destructive:\n \"destructive group border-destructive bg-destructive text-destructive-foreground\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n }\n)\n\nconst Toast = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef &\n VariantProps\n>(({ className, variant, ...props }, ref) => {\n return (\n \n )\n})\nToast.displayName = ToastPrimitives.Root.displayName\n\nconst ToastAction = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastAction.displayName = ToastPrimitives.Action.displayName\n\nconst ToastClose = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n \n \n))\nToastClose.displayName = ToastPrimitives.Close.displayName\n\nconst ToastTitle = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastTitle.displayName = ToastPrimitives.Title.displayName\n\nconst ToastDescription = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastDescription.displayName = ToastPrimitives.Description.displayName\n\ntype ToastProps = React.ComponentPropsWithoutRef\n\ntype ToastActionElement = React.ReactElement\n\nexport {\n type ToastProps,\n type ToastActionElement,\n ToastProvider,\n ToastViewport,\n Toast,\n ToastTitle,\n ToastDescription,\n ToastClose,\n ToastAction,\n}\n" + "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ToastPrimitives from \"@radix-ui/react-toast\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ToastProvider = ToastPrimitives.Provider\n\nconst ToastViewport = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastViewport.displayName = ToastPrimitives.Viewport.displayName\n\nconst toastVariants = cva(\n \"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full\",\n {\n variants: {\n variant: {\n default: \"border bg-background text-foreground\",\n destructive:\n \"destructive group border-destructive bg-destructive text-destructive-foreground\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n }\n)\n\nconst Toast = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef &\n VariantProps\n>(({ className, variant, ...props }, ref) => {\n return (\n \n )\n})\nToast.displayName = ToastPrimitives.Root.displayName\n\nconst ToastAction = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastAction.displayName = ToastPrimitives.Action.displayName\n\nconst ToastClose = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n \n \n))\nToastClose.displayName = ToastPrimitives.Close.displayName\n\nconst ToastTitle = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastTitle.displayName = ToastPrimitives.Title.displayName\n\nconst ToastDescription = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastDescription.displayName = ToastPrimitives.Description.displayName\n\ntype ToastProps = React.ComponentPropsWithoutRef\n\ntype ToastActionElement = React.ReactElement\n\nexport {\n type ToastProps,\n type ToastActionElement,\n ToastProvider,\n ToastViewport,\n Toast,\n ToastTitle,\n ToastDescription,\n ToastClose,\n ToastAction,\n}\n" }, { "name": "use-toast.ts", - "content": "\"use client\";\n\n// Inspired by react-hot-toast library\nimport * as React from \"react\"\n\nimport type {\n ToastActionElement,\n ToastProps,\n} from \"@/registry/default/ui/toast\"\n\nconst TOAST_LIMIT = 1\nconst TOAST_REMOVE_DELAY = 1000000\n\ntype ToasterToast = ToastProps & {\n id: string\n title?: React.ReactNode\n description?: React.ReactNode\n action?: ToastActionElement\n}\n\nconst actionTypes = {\n ADD_TOAST: \"ADD_TOAST\",\n UPDATE_TOAST: \"UPDATE_TOAST\",\n DISMISS_TOAST: \"DISMISS_TOAST\",\n REMOVE_TOAST: \"REMOVE_TOAST\",\n} as const\n\nlet count = 0\n\nfunction genId() {\n count = (count + 1) % Number.MAX_SAFE_INTEGER\n return count.toString()\n}\n\ntype ActionType = typeof actionTypes\n\ntype Action =\n | {\n type: ActionType[\"ADD_TOAST\"]\n toast: ToasterToast\n }\n | {\n type: ActionType[\"UPDATE_TOAST\"]\n toast: Partial\n }\n | {\n type: ActionType[\"DISMISS_TOAST\"]\n toastId?: ToasterToast[\"id\"]\n }\n | {\n type: ActionType[\"REMOVE_TOAST\"]\n toastId?: ToasterToast[\"id\"]\n }\n\ninterface State {\n toasts: ToasterToast[]\n}\n\nconst toastTimeouts = new Map>()\n\nconst addToRemoveQueue = (toastId: string) => {\n if (toastTimeouts.has(toastId)) {\n return\n }\n\n const timeout = setTimeout(() => {\n toastTimeouts.delete(toastId)\n dispatch({\n type: \"REMOVE_TOAST\",\n toastId: toastId,\n })\n }, TOAST_REMOVE_DELAY)\n\n toastTimeouts.set(toastId, timeout)\n}\n\nexport const reducer = (state: State, action: Action): State => {\n switch (action.type) {\n case \"ADD_TOAST\":\n return {\n ...state,\n toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),\n }\n\n case \"UPDATE_TOAST\":\n return {\n ...state,\n toasts: state.toasts.map((t) =>\n t.id === action.toast.id ? { ...t, ...action.toast } : t\n ),\n }\n\n case \"DISMISS_TOAST\": {\n const { toastId } = action\n\n // ! Side effects ! - This could be extracted into a dismissToast() action,\n // but I'll keep it here for simplicity\n if (toastId) {\n addToRemoveQueue(toastId)\n } else {\n state.toasts.forEach((toast) => {\n addToRemoveQueue(toast.id)\n })\n }\n\n return {\n ...state,\n toasts: state.toasts.map((t) =>\n t.id === toastId || toastId === undefined\n ? {\n ...t,\n open: false,\n }\n : t\n ),\n }\n }\n case \"REMOVE_TOAST\":\n if (action.toastId === undefined) {\n return {\n ...state,\n toasts: [],\n }\n }\n return {\n ...state,\n toasts: state.toasts.filter((t) => t.id !== action.toastId),\n }\n }\n}\n\nconst listeners: Array<(state: State) => void> = []\n\nlet memoryState: State = { toasts: [] }\n\nfunction dispatch(action: Action) {\n memoryState = reducer(memoryState, action)\n listeners.forEach((listener) => {\n listener(memoryState)\n })\n}\n\ntype Toast = Omit\n\nfunction toast({ ...props }: Toast) {\n const id = genId()\n\n const update = (props: ToasterToast) =>\n dispatch({\n type: \"UPDATE_TOAST\",\n toast: { ...props, id },\n })\n const dismiss = () => dispatch({ type: \"DISMISS_TOAST\", toastId: id })\n\n dispatch({\n type: \"ADD_TOAST\",\n toast: {\n ...props,\n id,\n open: true,\n onOpenChange: (open) => {\n if (!open) dismiss()\n },\n },\n })\n\n return {\n id: id,\n dismiss,\n update,\n }\n}\n\nfunction useToast() {\n const [state, setState] = React.useState(memoryState)\n\n React.useEffect(() => {\n listeners.push(setState)\n return () => {\n const index = listeners.indexOf(setState)\n if (index > -1) {\n listeners.splice(index, 1)\n }\n }\n }, [state])\n\n return {\n ...state,\n toast,\n dismiss: (toastId?: string) => dispatch({ type: \"DISMISS_TOAST\", toastId }),\n }\n}\n\nexport { useToast, toast }\n" + "content": "\"use client\"\n\n// Inspired by react-hot-toast library\nimport * as React from \"react\"\n\nimport type {\n ToastActionElement,\n ToastProps,\n} from \"@/registry/default/ui/toast\"\n\nconst TOAST_LIMIT = 1\nconst TOAST_REMOVE_DELAY = 1000000\n\ntype ToasterToast = ToastProps & {\n id: string\n title?: React.ReactNode\n description?: React.ReactNode\n action?: ToastActionElement\n}\n\nconst actionTypes = {\n ADD_TOAST: \"ADD_TOAST\",\n UPDATE_TOAST: \"UPDATE_TOAST\",\n DISMISS_TOAST: \"DISMISS_TOAST\",\n REMOVE_TOAST: \"REMOVE_TOAST\",\n} as const\n\nlet count = 0\n\nfunction genId() {\n count = (count + 1) % Number.MAX_SAFE_INTEGER\n return count.toString()\n}\n\ntype ActionType = typeof actionTypes\n\ntype Action =\n | {\n type: ActionType[\"ADD_TOAST\"]\n toast: ToasterToast\n }\n | {\n type: ActionType[\"UPDATE_TOAST\"]\n toast: Partial\n }\n | {\n type: ActionType[\"DISMISS_TOAST\"]\n toastId?: ToasterToast[\"id\"]\n }\n | {\n type: ActionType[\"REMOVE_TOAST\"]\n toastId?: ToasterToast[\"id\"]\n }\n\ninterface State {\n toasts: ToasterToast[]\n}\n\nconst toastTimeouts = new Map>()\n\nconst addToRemoveQueue = (toastId: string) => {\n if (toastTimeouts.has(toastId)) {\n return\n }\n\n const timeout = setTimeout(() => {\n toastTimeouts.delete(toastId)\n dispatch({\n type: \"REMOVE_TOAST\",\n toastId: toastId,\n })\n }, TOAST_REMOVE_DELAY)\n\n toastTimeouts.set(toastId, timeout)\n}\n\nexport const reducer = (state: State, action: Action): State => {\n switch (action.type) {\n case \"ADD_TOAST\":\n return {\n ...state,\n toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),\n }\n\n case \"UPDATE_TOAST\":\n return {\n ...state,\n toasts: state.toasts.map((t) =>\n t.id === action.toast.id ? { ...t, ...action.toast } : t\n ),\n }\n\n case \"DISMISS_TOAST\": {\n const { toastId } = action\n\n // ! Side effects ! - This could be extracted into a dismissToast() action,\n // but I'll keep it here for simplicity\n if (toastId) {\n addToRemoveQueue(toastId)\n } else {\n state.toasts.forEach((toast) => {\n addToRemoveQueue(toast.id)\n })\n }\n\n return {\n ...state,\n toasts: state.toasts.map((t) =>\n t.id === toastId || toastId === undefined\n ? {\n ...t,\n open: false,\n }\n : t\n ),\n }\n }\n case \"REMOVE_TOAST\":\n if (action.toastId === undefined) {\n return {\n ...state,\n toasts: [],\n }\n }\n return {\n ...state,\n toasts: state.toasts.filter((t) => t.id !== action.toastId),\n }\n }\n}\n\nconst listeners: Array<(state: State) => void> = []\n\nlet memoryState: State = { toasts: [] }\n\nfunction dispatch(action: Action) {\n memoryState = reducer(memoryState, action)\n listeners.forEach((listener) => {\n listener(memoryState)\n })\n}\n\ntype Toast = Omit\n\nfunction toast({ ...props }: Toast) {\n const id = genId()\n\n const update = (props: ToasterToast) =>\n dispatch({\n type: \"UPDATE_TOAST\",\n toast: { ...props, id },\n })\n const dismiss = () => dispatch({ type: \"DISMISS_TOAST\", toastId: id })\n\n dispatch({\n type: \"ADD_TOAST\",\n toast: {\n ...props,\n id,\n open: true,\n onOpenChange: (open) => {\n if (!open) dismiss()\n },\n },\n })\n\n return {\n id: id,\n dismiss,\n update,\n }\n}\n\nfunction useToast() {\n const [state, setState] = React.useState(memoryState)\n\n React.useEffect(() => {\n listeners.push(setState)\n return () => {\n const index = listeners.indexOf(setState)\n if (index > -1) {\n listeners.splice(index, 1)\n }\n }\n }, [state])\n\n return {\n ...state,\n toast,\n dismiss: (toastId?: string) => dispatch({ type: \"DISMISS_TOAST\", toastId }),\n }\n}\n\nexport { useToast, toast }\n" }, { "name": "toaster.tsx", diff --git a/apps/www/public/registry/styles/new-york/otp-input.json b/apps/www/public/registry/styles/new-york/otp-input.json index 2ac70e4e66d..d236cf17814 100644 --- a/apps/www/public/registry/styles/new-york/otp-input.json +++ b/apps/www/public/registry/styles/new-york/otp-input.json @@ -3,7 +3,7 @@ "files": [ { "name": "otp-input.tsx", - "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Input } from \"@/registry/new-york/ui/input\";\n\ntype AllowedInputTypes = 'password' | 'text' | 'number' | 'tel';\n\ninterface OTPInputProps\n extends Pick<\n React.InputHTMLAttributes,\n | \"onPaste\"\n | \"pattern\"\n | \"autoFocus\"\n | \"className\"\n | \"id\"\n | \"name\"\n > {\n /** Value of the OTP input */\n value?: string\n /** Callback to be called when the OTP value changes */\n onChange?: (otp: string) => void\n /** Callback to be called when pasting content into the component */\n onPaste?: (event: React.ClipboardEvent) => void;\n /** Number of OTP inputs to be rendered */\n numInputs?: number\n /** Placeholder for the inputs */\n placeholder?: string;\n /** Type of the input */\n type?: AllowedInputTypes\n /** Function to render the separator */\n renderSeparator?: ((index: number) => React.ReactNode) | React.ReactNode;\n}\n\nexport const OTPInput = ({\n value = \"\",\n numInputs = 6,\n onChange,\n type = \"text\",\n placeholder = \"_\",\n pattern = \"[0-9]\",\n autoFocus = true,\n className,\n id,\n name,\n onPaste,\n renderSeparator,\n ...rest\n}: OTPInputProps) => {\n const [otpValue, setOTPValue] = React.useState(value)\n const [activeInput, setActiveInput] = React.useState(0)\n const inputRefs = React.useRef>([])\n\n const getOTPValue = () => (otpValue ? otpValue.toString().split(\"\") : [])\n\n const isInputNum = type === \"number\" || type === \"tel\"\n\n React.useEffect(() => {\n inputRefs.current = inputRefs.current.slice(0, numInputs)\n }, [numInputs])\n\n React.useEffect(() => {\n if (autoFocus) {\n inputRefs.current[0]?.focus()\n }\n }, [autoFocus])\n\n const isInputValueValid = (value: string) => {\n const isTypeValid = isInputNum\n ? !isNaN(Number(value))\n : typeof value === \"string\"\n return isTypeValid && value.trim().length === 1\n }\n\n const handleChange = (event: React.ChangeEvent) => {\n const { value } = event.target\n\n if (isInputValueValid(value)) {\n changeCodeAtFocus(value)\n focusInput(activeInput + 1)\n }\n }\n\n const handleFocus =\n (event: React.FocusEvent) => (index: number) => {\n setActiveInput(index)\n event.target.select()\n }\n\n const handleBlur = () => {\n setActiveInput(activeInput - 1)\n }\n\n const handleKeyDown = (event: React.KeyboardEvent) => {\n const otp = getOTPValue()\n if ([event.code, event.key].includes(\"Backspace\")) {\n event.preventDefault()\n changeCodeAtFocus(\"\")\n focusInput(activeInput - 1)\n } else if (event.code === \"Delete\") {\n event.preventDefault()\n changeCodeAtFocus(\"\")\n } else if (event.code === \"ArrowLeft\") {\n event.preventDefault()\n focusInput(activeInput - 1)\n } else if (event.code === \"ArrowRight\") {\n event.preventDefault()\n focusInput(activeInput + 1)\n }\n // React does not trigger onChange when the same value is entered\n // again. So we need to focus the next input manually in this case.\n else if (event.key === otp[activeInput]) {\n event.preventDefault()\n focusInput(activeInput + 1)\n } else if (\n event.code === \"Spacebar\" ||\n event.code === \"Space\" ||\n event.code === \"ArrowUp\" ||\n event.code === \"ArrowDown\"\n ) {\n event.preventDefault()\n } else if (isInputNum && !isInputValueValid(event.key)) {\n event.preventDefault()\n }\n }\n\n const focusInput = (index: number) => {\n const activeInput = Math.max(Math.min(numInputs - 1, index), 0)\n\n if (inputRefs.current[activeInput]) {\n inputRefs.current[activeInput]?.focus()\n inputRefs.current[activeInput]?.select()\n setActiveInput(activeInput)\n }\n }\n\n const changeCodeAtFocus = (value: string) => {\n const otp = getOTPValue()\n otp[activeInput] = value[0]\n handleOTPChange(otp)\n }\n\n const handleOTPChange = (otp: Array) => {\n const otpValue = otp.join(\"\")\n setOTPValue(otpValue)\n onChange?.(otpValue)\n }\n\n const handlePaste = (event: React.ClipboardEvent) => {\n event.preventDefault()\n\n const otp = getOTPValue()\n let nextActiveInput = activeInput\n\n // Get pastedData in an array of max size (num of inputs - current position)\n const pastedData = event.clipboardData\n .getData(\"text/plain\")\n .slice(0, numInputs - activeInput)\n .split(\"\")\n\n // Prevent pasting if the clipboard data contains non-numeric values for number inputs\n if (isInputNum && pastedData.some((value) => isNaN(Number(value)))) {\n return\n }\n\n // Paste data from focused input onwards\n for (let pos = 0; pos < numInputs; ++pos) {\n if (pos >= activeInput && pastedData.length > 0) {\n otp[pos] = pastedData.shift() ?? \"\"\n nextActiveInput++\n }\n }\n\n focusInput(nextActiveInput)\n handleOTPChange(otp)\n }\n\n return (\n
\n {Array.from({ length: numInputs }, (_, index) => index).map((i) => (\n \n (inputRefs.current[i] = element)}\n onChange={handleChange}\n onFocus={(event) => handleFocus(event)(i)}\n onBlur={handleBlur}\n onKeyDown={handleKeyDown}\n onPaste={handlePaste}\n autoComplete=\"one-time-code\"\n maxLength={1}\n size={1}\n className={cn(\"text-center font-bold\", className)}\n pattern={pattern}\n {...rest}\n />\n {i < numInputs - 1 && (typeof renderSeparator === 'function' ? renderSeparator(i) : renderSeparator)}\n \n ))}\n \n
\n )\n}\n" + "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Input } from \"@/registry/new-york/ui/input\";\n\ntype AllowedInputTypes = 'password' | 'text' | 'number' | 'tel';\n\ninterface OTPInputProps\n extends Pick<\n React.InputHTMLAttributes,\n | \"pattern\"\n | \"autoFocus\"\n | \"id\"\n | \"name\"\n > {\n /** Value of the OTP input */\n value?: string\n /** Callback to be called when the OTP value changes */\n onChange?: (otp: string) => void\n /** Callback to be called when pasting content into the component */\n onPaste?: (event: React.ClipboardEvent) => void;\n /** Callback to be called when the input is focused */\n onFocus?: (event: React.FocusEvent) => void;\n /** Callback to be called when the input is blurred */\n onBlur?: (event: React.FocusEvent) => void;\n /** Callback to be called when a key is pressed */\n onKeyDown?: (event: React.KeyboardEvent) => void;\n /** Callback to be called when the input value changes */\n onInput?: (event: React.FormEvent) => void;\n /** Number of OTP inputs to be rendered */\n numInputs?: number\n /** Placeholder for the inputs */\n placeholder?: string;\n /** Type of the input */\n type?: AllowedInputTypes\n /** Function to render the separator */\n renderSeparator?: ((index: number) => React.ReactNode) | React.ReactNode;\n /** Additional styles for the component */\n styles?: {\n container?: string;\n input?: string | ((index: number) => React.ReactNode)\n }\n /** Focus the last input */\n lastInputFocused?: boolean;\n}\n\nexport const OTPInput = ({\n value = \"\",\n numInputs = 6,\n onChange,\n onPaste,\n onFocus,\n onBlur,\n onKeyDown,\n onInput,\n type = \"text\",\n placeholder = \"_\",\n pattern = \"[0-9]\",\n autoFocus = false,\n lastInputFocused = false,\n styles,\n id,\n name,\n renderSeparator,\n ...rest\n}: OTPInputProps) => {\n const [otpValue, setOTPValue] = React.useState(\n new Array(numInputs).fill(\"\").map((_, index) => value[index] ?? \"\") \n )\n const [activeInput, setActiveInput] = React.useState(0)\n const inputRefs = React.useRef>([])\n\n const getOTPValue = () => otpValue\n\n const isInputNum = type === \"number\" || type === \"tel\"\n\n React.useEffect(() => {\n inputRefs.current = inputRefs.current.slice(0, numInputs)\n }, [numInputs])\n\n React.useEffect(() => {\n if (autoFocus) {\n inputRefs.current[0]?.focus()\n } else if (lastInputFocused) {\n focusInput(numInputs - 1);\n }\n }, [autoFocus, lastInputFocused])\n\n const isInputValueValid = (value: string) => {\n const isTypeValid = isInputNum\n ? !isNaN(Number(value))\n : typeof value === \"string\"\n return isTypeValid && value.trim().length === 1\n }\n\n const handleChange = (event: React.ChangeEvent) => {\n const { value } = event.target\n\n if (isInputValueValid(value)) {\n changeCodeAtFocus(value)\n focusInput(activeInput + 1)\n }\n }\n\n const handleFocus =\n (event: React.FocusEvent) => (index: number) => {\n const otp = getOTPValue()\n if(otp[index] === \"\") {\n const lastFilledIndex = otp.findIndex((value) => value === \"\")\n setActiveInput(lastFilledIndex)\n inputRefs.current[lastFilledIndex]?.select()\n return\n } else {\n setActiveInput(index)\n event.target.select()\n }\n onFocus?.(event)\n }\n\n const handleBlur = (event: React.FocusEvent) => {\n setActiveInput(activeInput - 1)\n onBlur?.(event)\n }\n\n const handleKeyDown = (event: React.KeyboardEvent) => {\n const otp = getOTPValue()\n if ([event.code, event.key].includes(\"Backspace\")) {\n event.preventDefault()\n changeCodeAtFocus(\"\")\n focusInput(activeInput - 1)\n } else if (event.code === \"Delete\") {\n event.preventDefault()\n changeCodeAtFocus(\"\")\n } else if (event.code === \"ArrowLeft\") {\n event.preventDefault()\n focusInput(activeInput - 1)\n } else if (event.code === \"ArrowRight\") {\n event.preventDefault()\n if (otp[activeInput]) {\n focusInput(activeInput + 1)\n }\n }\n // React does not trigger onChange when the same value is entered\n // again. So we need to focus the next input manually in this case.\n else if (event.key === otp[activeInput]) {\n event.preventDefault()\n focusInput(activeInput + 1)\n } else if (\n event.code === \"Spacebar\" ||\n event.code === \"Space\" ||\n event.code === \"ArrowUp\" ||\n event.code === \"ArrowDown\"\n ) {\n event.preventDefault()\n } else if (isInputNum && !isInputValueValid(event.key)) {\n event.preventDefault()\n }\n onKeyDown?.(event)\n }\n\n const focusInput = (index: number) => {\n const activeInput = Math.max(Math.min(numInputs - 1, index), 0)\n\n if (inputRefs.current[activeInput]) {\n inputRefs.current[activeInput]?.focus()\n inputRefs.current[activeInput]?.select()\n setActiveInput(activeInput)\n }\n }\n\n const changeCodeAtFocus = (value: string) => {\n let otp = getOTPValue()\n if(value === \"\") {\n // If the value is empty, then move the values to the left (e.g Backspace is pressed)\n let newOtp = otp.slice(0, activeInput).concat(otp.slice(activeInput + 1))\n newOtp = newOtp.concat(new Array(numInputs - newOtp.length).fill(\"\"))\n handleOTPChange(newOtp)\n } else {\n otp[activeInput] = value\n handleOTPChange(otp)\n }\n }\n\n const handleOTPChange = (otp: Array) => {\n setOTPValue([...otp, ...new Array(numInputs - otp.length).fill(\"\")])\n onChange?.(otp.join(\"\"))\n }\n\n const handlePaste = (event: React.ClipboardEvent) => {\n event.preventDefault()\n\n const otp = getOTPValue()\n let nextActiveInput = activeInput\n\n // Get pastedData in an array of max size (num of inputs - current position)\n const pastedData = event.clipboardData\n .getData(\"text/plain\")\n .slice(0, numInputs - activeInput)\n .split(\"\")\n\n // Prevent pasting if the clipboard data contains non-numeric values for number inputs\n if (isInputNum && pastedData.some((value) => isNaN(Number(value)))) {\n return\n }\n\n // Paste data from focused input onwards\n for (let pos = 0; pos < numInputs; ++pos) {\n if (pos >= activeInput && pastedData.length > 0) {\n otp[pos] = pastedData.shift() ?? \"\"\n nextActiveInput++\n }\n }\n\n focusInput(nextActiveInput)\n handleOTPChange(otp)\n onPaste?.(event)\n }\n\n return (\n
\n {Array.from({ length: numInputs }, (_, index) => index).map((i) => (\n \n (inputRefs.current[i] = element)}\n onChange={handleChange}\n onFocus={(event) => handleFocus(event)(i)}\n onBlur={handleBlur}\n onKeyDown={handleKeyDown}\n onPaste={handlePaste}\n onInput={onInput}\n autoComplete=\"one-time-code\"\n maxLength={1}\n size={1}\n className={cn(\"text-center font-bold\", typeof styles?.input === \"function\" ? styles.input(i) : styles?.input)}\n pattern={pattern}\n {...rest}\n />\n {typeof renderSeparator === 'function' ? renderSeparator(i) : i < numInputs - 1 && renderSeparator}\n \n ))}\n \n
\n )\n}\n" } ], "type": "components:ui" diff --git a/apps/www/public/registry/styles/new-york/toast.json b/apps/www/public/registry/styles/new-york/toast.json index 203576741d1..a63c77943b3 100644 --- a/apps/www/public/registry/styles/new-york/toast.json +++ b/apps/www/public/registry/styles/new-york/toast.json @@ -6,11 +6,11 @@ "files": [ { "name": "toast.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\"\nimport { Cross2Icon } from \"@radix-ui/react-icons\"\nimport * as ToastPrimitives from \"@radix-ui/react-toast\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ToastProvider = ToastPrimitives.Provider\n\nconst ToastViewport = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastViewport.displayName = ToastPrimitives.Viewport.displayName\n\nconst toastVariants = cva(\n \"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full\",\n {\n variants: {\n variant: {\n default: \"border bg-background text-foreground\",\n destructive:\n \"destructive group border-destructive bg-destructive text-destructive-foreground\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n }\n)\n\nconst Toast = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef &\n VariantProps\n>(({ className, variant, ...props }, ref) => {\n return (\n \n )\n})\nToast.displayName = ToastPrimitives.Root.displayName\n\nconst ToastAction = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastAction.displayName = ToastPrimitives.Action.displayName\n\nconst ToastClose = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n \n \n))\nToastClose.displayName = ToastPrimitives.Close.displayName\n\nconst ToastTitle = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastTitle.displayName = ToastPrimitives.Title.displayName\n\nconst ToastDescription = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastDescription.displayName = ToastPrimitives.Description.displayName\n\ntype ToastProps = React.ComponentPropsWithoutRef\n\ntype ToastActionElement = React.ReactElement\n\nexport {\n type ToastProps,\n type ToastActionElement,\n ToastProvider,\n ToastViewport,\n Toast,\n ToastTitle,\n ToastDescription,\n ToastClose,\n ToastAction,\n}\n" + "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Cross2Icon } from \"@radix-ui/react-icons\"\nimport * as ToastPrimitives from \"@radix-ui/react-toast\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ToastProvider = ToastPrimitives.Provider\n\nconst ToastViewport = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastViewport.displayName = ToastPrimitives.Viewport.displayName\n\nconst toastVariants = cva(\n \"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full\",\n {\n variants: {\n variant: {\n default: \"border bg-background text-foreground\",\n destructive:\n \"destructive group border-destructive bg-destructive text-destructive-foreground\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n }\n)\n\nconst Toast = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef &\n VariantProps\n>(({ className, variant, ...props }, ref) => {\n return (\n \n )\n})\nToast.displayName = ToastPrimitives.Root.displayName\n\nconst ToastAction = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastAction.displayName = ToastPrimitives.Action.displayName\n\nconst ToastClose = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n \n \n))\nToastClose.displayName = ToastPrimitives.Close.displayName\n\nconst ToastTitle = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastTitle.displayName = ToastPrimitives.Title.displayName\n\nconst ToastDescription = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n))\nToastDescription.displayName = ToastPrimitives.Description.displayName\n\ntype ToastProps = React.ComponentPropsWithoutRef\n\ntype ToastActionElement = React.ReactElement\n\nexport {\n type ToastProps,\n type ToastActionElement,\n ToastProvider,\n ToastViewport,\n Toast,\n ToastTitle,\n ToastDescription,\n ToastClose,\n ToastAction,\n}\n" }, { "name": "use-toast.ts", - "content": "\"use client\";\n\n// Inspired by react-hot-toast library\nimport * as React from \"react\"\n\nimport type {\n ToastActionElement,\n ToastProps,\n} from \"@/registry/default/ui/toast\"\n\nconst TOAST_LIMIT = 1\nconst TOAST_REMOVE_DELAY = 1000000\n\ntype ToasterToast = ToastProps & {\n id: string\n title?: React.ReactNode\n description?: React.ReactNode\n action?: ToastActionElement\n}\n\nconst actionTypes = {\n ADD_TOAST: \"ADD_TOAST\",\n UPDATE_TOAST: \"UPDATE_TOAST\",\n DISMISS_TOAST: \"DISMISS_TOAST\",\n REMOVE_TOAST: \"REMOVE_TOAST\",\n} as const\n\nlet count = 0\n\nfunction genId() {\n count = (count + 1) % Number.MAX_SAFE_INTEGER\n return count.toString()\n}\n\ntype ActionType = typeof actionTypes\n\ntype Action =\n | {\n type: ActionType[\"ADD_TOAST\"]\n toast: ToasterToast\n }\n | {\n type: ActionType[\"UPDATE_TOAST\"]\n toast: Partial\n }\n | {\n type: ActionType[\"DISMISS_TOAST\"]\n toastId?: ToasterToast[\"id\"]\n }\n | {\n type: ActionType[\"REMOVE_TOAST\"]\n toastId?: ToasterToast[\"id\"]\n }\n\ninterface State {\n toasts: ToasterToast[]\n}\n\nconst toastTimeouts = new Map>()\n\nconst addToRemoveQueue = (toastId: string) => {\n if (toastTimeouts.has(toastId)) {\n return\n }\n\n const timeout = setTimeout(() => {\n toastTimeouts.delete(toastId)\n dispatch({\n type: \"REMOVE_TOAST\",\n toastId: toastId,\n })\n }, TOAST_REMOVE_DELAY)\n\n toastTimeouts.set(toastId, timeout)\n}\n\nexport const reducer = (state: State, action: Action): State => {\n switch (action.type) {\n case \"ADD_TOAST\":\n return {\n ...state,\n toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),\n }\n\n case \"UPDATE_TOAST\":\n return {\n ...state,\n toasts: state.toasts.map((t) =>\n t.id === action.toast.id ? { ...t, ...action.toast } : t\n ),\n }\n\n case \"DISMISS_TOAST\": {\n const { toastId } = action\n\n // ! Side effects ! - This could be extracted into a dismissToast() action,\n // but I'll keep it here for simplicity\n if (toastId) {\n addToRemoveQueue(toastId)\n } else {\n state.toasts.forEach((toast) => {\n addToRemoveQueue(toast.id)\n })\n }\n\n return {\n ...state,\n toasts: state.toasts.map((t) =>\n t.id === toastId || toastId === undefined\n ? {\n ...t,\n open: false,\n }\n : t\n ),\n }\n }\n case \"REMOVE_TOAST\":\n if (action.toastId === undefined) {\n return {\n ...state,\n toasts: [],\n }\n }\n return {\n ...state,\n toasts: state.toasts.filter((t) => t.id !== action.toastId),\n }\n }\n}\n\nconst listeners: Array<(state: State) => void> = []\n\nlet memoryState: State = { toasts: [] }\n\nfunction dispatch(action: Action) {\n memoryState = reducer(memoryState, action)\n listeners.forEach((listener) => {\n listener(memoryState)\n })\n}\n\ntype Toast = Omit\n\nfunction toast({ ...props }: Toast) {\n const id = genId()\n\n const update = (props: ToasterToast) =>\n dispatch({\n type: \"UPDATE_TOAST\",\n toast: { ...props, id },\n })\n const dismiss = () => dispatch({ type: \"DISMISS_TOAST\", toastId: id })\n\n dispatch({\n type: \"ADD_TOAST\",\n toast: {\n ...props,\n id,\n open: true,\n onOpenChange: (open) => {\n if (!open) dismiss()\n },\n },\n })\n\n return {\n id: id,\n dismiss,\n update,\n }\n}\n\nfunction useToast() {\n const [state, setState] = React.useState(memoryState)\n\n React.useEffect(() => {\n listeners.push(setState)\n return () => {\n const index = listeners.indexOf(setState)\n if (index > -1) {\n listeners.splice(index, 1)\n }\n }\n }, [state])\n\n return {\n ...state,\n toast,\n dismiss: (toastId?: string) => dispatch({ type: \"DISMISS_TOAST\", toastId }),\n }\n}\n\nexport { useToast, toast }\n" + "content": "\"use client\"\n\n// Inspired by react-hot-toast library\nimport * as React from \"react\"\n\nimport type {\n ToastActionElement,\n ToastProps,\n} from \"@/registry/default/ui/toast\"\n\nconst TOAST_LIMIT = 1\nconst TOAST_REMOVE_DELAY = 1000000\n\ntype ToasterToast = ToastProps & {\n id: string\n title?: React.ReactNode\n description?: React.ReactNode\n action?: ToastActionElement\n}\n\nconst actionTypes = {\n ADD_TOAST: \"ADD_TOAST\",\n UPDATE_TOAST: \"UPDATE_TOAST\",\n DISMISS_TOAST: \"DISMISS_TOAST\",\n REMOVE_TOAST: \"REMOVE_TOAST\",\n} as const\n\nlet count = 0\n\nfunction genId() {\n count = (count + 1) % Number.MAX_SAFE_INTEGER\n return count.toString()\n}\n\ntype ActionType = typeof actionTypes\n\ntype Action =\n | {\n type: ActionType[\"ADD_TOAST\"]\n toast: ToasterToast\n }\n | {\n type: ActionType[\"UPDATE_TOAST\"]\n toast: Partial\n }\n | {\n type: ActionType[\"DISMISS_TOAST\"]\n toastId?: ToasterToast[\"id\"]\n }\n | {\n type: ActionType[\"REMOVE_TOAST\"]\n toastId?: ToasterToast[\"id\"]\n }\n\ninterface State {\n toasts: ToasterToast[]\n}\n\nconst toastTimeouts = new Map>()\n\nconst addToRemoveQueue = (toastId: string) => {\n if (toastTimeouts.has(toastId)) {\n return\n }\n\n const timeout = setTimeout(() => {\n toastTimeouts.delete(toastId)\n dispatch({\n type: \"REMOVE_TOAST\",\n toastId: toastId,\n })\n }, TOAST_REMOVE_DELAY)\n\n toastTimeouts.set(toastId, timeout)\n}\n\nexport const reducer = (state: State, action: Action): State => {\n switch (action.type) {\n case \"ADD_TOAST\":\n return {\n ...state,\n toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),\n }\n\n case \"UPDATE_TOAST\":\n return {\n ...state,\n toasts: state.toasts.map((t) =>\n t.id === action.toast.id ? { ...t, ...action.toast } : t\n ),\n }\n\n case \"DISMISS_TOAST\": {\n const { toastId } = action\n\n // ! Side effects ! - This could be extracted into a dismissToast() action,\n // but I'll keep it here for simplicity\n if (toastId) {\n addToRemoveQueue(toastId)\n } else {\n state.toasts.forEach((toast) => {\n addToRemoveQueue(toast.id)\n })\n }\n\n return {\n ...state,\n toasts: state.toasts.map((t) =>\n t.id === toastId || toastId === undefined\n ? {\n ...t,\n open: false,\n }\n : t\n ),\n }\n }\n case \"REMOVE_TOAST\":\n if (action.toastId === undefined) {\n return {\n ...state,\n toasts: [],\n }\n }\n return {\n ...state,\n toasts: state.toasts.filter((t) => t.id !== action.toastId),\n }\n }\n}\n\nconst listeners: Array<(state: State) => void> = []\n\nlet memoryState: State = { toasts: [] }\n\nfunction dispatch(action: Action) {\n memoryState = reducer(memoryState, action)\n listeners.forEach((listener) => {\n listener(memoryState)\n })\n}\n\ntype Toast = Omit\n\nfunction toast({ ...props }: Toast) {\n const id = genId()\n\n const update = (props: ToasterToast) =>\n dispatch({\n type: \"UPDATE_TOAST\",\n toast: { ...props, id },\n })\n const dismiss = () => dispatch({ type: \"DISMISS_TOAST\", toastId: id })\n\n dispatch({\n type: \"ADD_TOAST\",\n toast: {\n ...props,\n id,\n open: true,\n onOpenChange: (open) => {\n if (!open) dismiss()\n },\n },\n })\n\n return {\n id: id,\n dismiss,\n update,\n }\n}\n\nfunction useToast() {\n const [state, setState] = React.useState(memoryState)\n\n React.useEffect(() => {\n listeners.push(setState)\n return () => {\n const index = listeners.indexOf(setState)\n if (index > -1) {\n listeners.splice(index, 1)\n }\n }\n }, [state])\n\n return {\n ...state,\n toast,\n dismiss: (toastId?: string) => dispatch({ type: \"DISMISS_TOAST\", toastId }),\n }\n}\n\nexport { useToast, toast }\n" }, { "name": "toaster.tsx", diff --git a/apps/www/registry/default/example/otp-input-controlled.tsx b/apps/www/registry/default/example/otp-input-controlled.tsx index 053eceb11ee..a8bcf249f74 100644 --- a/apps/www/registry/default/example/otp-input-controlled.tsx +++ b/apps/www/registry/default/example/otp-input-controlled.tsx @@ -1,15 +1,16 @@ -import { OTPInput } from "@/registry/default/ui/otp-input"; -import * as React from "react"; +import * as React from "react" -export default function OtpInputDemo() { +import { OTPInput } from "@/registry/default/ui/otp-input" +export default function OtpInputDemo() { const [otp, setOtp] = React.useState("123456") return (

- OTP Value: {otp} + OTP Value: + {otp}

) -} \ No newline at end of file +} diff --git a/apps/www/registry/default/example/otp-input-custom-number.tsx b/apps/www/registry/default/example/otp-input-custom-number.tsx index ac37d5c3058..2c5bf0e5e06 100644 --- a/apps/www/registry/default/example/otp-input-custom-number.tsx +++ b/apps/www/registry/default/example/otp-input-custom-number.tsx @@ -1,5 +1,5 @@ -import { OTPInput } from "@/registry/default/ui/otp-input"; +import { OTPInput } from "@/registry/default/ui/otp-input" export default function OtpInputDemo() { return -} \ No newline at end of file +} diff --git a/apps/www/registry/default/example/otp-input-custom-style.tsx b/apps/www/registry/default/example/otp-input-custom-style.tsx new file mode 100644 index 00000000000..861addc63b3 --- /dev/null +++ b/apps/www/registry/default/example/otp-input-custom-style.tsx @@ -0,0 +1,32 @@ +import { cn } from "@/lib/utils" +import { OTPInput } from "@/registry/default/ui/otp-input" + +export default function OtpInputCustomStyleDemo() { + const roundedLeftByIndex = [0, 3] + const roundedRightByIndex = [2, 5] + return ( +
+ + cn( + "relative w-14 h-14 text-[2rem]", + "flex items-center justify-center", + "rounded-none", + roundedLeftByIndex.includes(index) && + "rounded-l-md rounded-r-none", + roundedRightByIndex.includes(index) && + "rounded-r-md rounded-l-none" + ), + }} + renderSeparator={(index) => { + if (index === 2) { + return - + } + }} + placeholder="" + /> +
+ ) +} diff --git a/apps/www/registry/default/example/otp-input-demo.tsx b/apps/www/registry/default/example/otp-input-demo.tsx index 6af8ee84459..2d9bc54b73e 100644 --- a/apps/www/registry/default/example/otp-input-demo.tsx +++ b/apps/www/registry/default/example/otp-input-demo.tsx @@ -1,5 +1,5 @@ -import { OTPInput } from "@/registry/default/ui/otp-input"; +import { OTPInput } from "@/registry/default/ui/otp-input" export default function OtpInputDemo() { return -} \ No newline at end of file +} diff --git a/apps/www/registry/default/example/otp-input-form.tsx b/apps/www/registry/default/example/otp-input-form.tsx index fd9b24a8cb7..1aede845880 100644 --- a/apps/www/registry/default/example/otp-input-form.tsx +++ b/apps/www/registry/default/example/otp-input-form.tsx @@ -14,8 +14,8 @@ import { FormLabel, FormMessage, } from "@/registry/default/ui/form" -import { toast } from "@/registry/default/ui/use-toast" import { OTPInput } from "@/registry/default/ui/otp-input" +import { toast } from "@/registry/default/ui/use-toast" const FormSchema = z.object({ otp: z.string().min(6, { diff --git a/apps/www/registry/default/example/otp-input-on-paste.tsx b/apps/www/registry/default/example/otp-input-on-paste.tsx index 04bf545a3e5..66c9f682464 100644 --- a/apps/www/registry/default/example/otp-input-on-paste.tsx +++ b/apps/www/registry/default/example/otp-input-on-paste.tsx @@ -1,14 +1,14 @@ -import { OTPInput } from "@/registry/default/ui/otp-input"; +import { OTPInput } from "@/registry/default/ui/otp-input" export default function OtpInputDemo() { return ( - { e.preventDefault() const clipboardData = e.clipboardData const pastedData = clipboardData.getData("text") alert(`Pasted data: ${pastedData}`) - }} + }} /> ) -} \ No newline at end of file +} diff --git a/apps/www/registry/default/example/otp-input-placeholder.tsx b/apps/www/registry/default/example/otp-input-placeholder.tsx index 1a78d3417de..b667e625b93 100644 --- a/apps/www/registry/default/example/otp-input-placeholder.tsx +++ b/apps/www/registry/default/example/otp-input-placeholder.tsx @@ -1,5 +1,5 @@ -import { OTPInput } from "@/registry/default/ui/otp-input"; +import { OTPInput } from "@/registry/default/ui/otp-input" export default function OtpInputDemo() { return -} \ No newline at end of file +} diff --git a/apps/www/registry/default/example/otp-input-separator.tsx b/apps/www/registry/default/example/otp-input-separator.tsx index df8303f522c..d6aa4c97fb4 100644 --- a/apps/www/registry/default/example/otp-input-separator.tsx +++ b/apps/www/registry/default/example/otp-input-separator.tsx @@ -1,5 +1,5 @@ -import { OTPInput } from "@/registry/default/ui/otp-input"; +import { OTPInput } from "@/registry/default/ui/otp-input" export default function OtpInputDemo() { return -} placeholder="" /> -} \ No newline at end of file +} diff --git a/apps/www/registry/default/ui/otp-input.tsx b/apps/www/registry/default/ui/otp-input.tsx index c5d1a4a053b..de4a98ef690 100644 --- a/apps/www/registry/default/ui/otp-input.tsx +++ b/apps/www/registry/default/ui/otp-input.tsx @@ -3,56 +3,73 @@ import * as React from "react" import { cn } from "@/lib/utils" -import { Input } from "@/registry/default/ui/input"; +import { Input } from "@/registry/default/ui/input" -type AllowedInputTypes = 'password' | 'text' | 'number' | 'tel'; +type AllowedInputTypes = "password" | "text" | "number" | "tel" interface OTPInputProps extends Pick< React.InputHTMLAttributes, - | "onPaste" - | "pattern" - | "autoFocus" - | "className" - | "id" - | "name" + "pattern" | "autoFocus" | "id" | "name" > { /** Value of the OTP input */ value?: string /** Callback to be called when the OTP value changes */ onChange?: (otp: string) => void /** Callback to be called when pasting content into the component */ - onPaste?: (event: React.ClipboardEvent) => void; + onPaste?: (event: React.ClipboardEvent) => void + /** Callback to be called when the input is focused */ + onFocus?: (event: React.FocusEvent) => void + /** Callback to be called when the input is blurred */ + onBlur?: (event: React.FocusEvent) => void + /** Callback to be called when a key is pressed */ + onKeyDown?: (event: React.KeyboardEvent) => void + /** Callback to be called when the input value changes */ + onInput?: (event: React.FormEvent) => void /** Number of OTP inputs to be rendered */ numInputs?: number /** Placeholder for the inputs */ - placeholder?: string; - /** Type of the input */ + placeholder?: string + /** Type of the input */ type?: AllowedInputTypes /** Function to render the separator */ - renderSeparator?: ((index: number) => React.ReactNode) | React.ReactNode; + renderSeparator?: ((index: number) => React.ReactNode) | React.ReactNode + /** Additional styles for the component */ + styles?: { + container?: string + input?: string | ((index: number) => React.ReactNode) + } + /** Focus the last input */ + lastInputFocused?: boolean } export const OTPInput = ({ value = "", numInputs = 6, onChange, + onPaste, + onFocus, + onBlur, + onKeyDown, + onInput, type = "text", placeholder = "_", pattern = "[0-9]", autoFocus = false, - className, + lastInputFocused = false, + styles, id, - name, - onPaste, + name = "otp-input", renderSeparator, ...rest }: OTPInputProps) => { - const [otpValue, setOTPValue] = React.useState(value) + const [otpValue, setOTPValue] = React.useState( + new Array(numInputs).fill("").map((_, index) => value[index] ?? "") + ) const [activeInput, setActiveInput] = React.useState(0) const inputRefs = React.useRef>([]) - const getOTPValue = () => (otpValue ? otpValue.toString().split("") : []) + const getOTPValue = () => otpValue const isInputNum = type === "number" || type === "tel" @@ -63,8 +80,10 @@ export const OTPInput = ({ React.useEffect(() => { if (autoFocus) { inputRefs.current[0]?.focus() + } else if (lastInputFocused) { + focusInput(numInputs - 1) } - }, [autoFocus]) + }, [autoFocus, lastInputFocused]) const isInputValueValid = (value: string) => { const isTypeValid = isInputNum @@ -84,12 +103,22 @@ export const OTPInput = ({ const handleFocus = (event: React.FocusEvent) => (index: number) => { - setActiveInput(index) - event.target.select() + const otp = getOTPValue() + if (otp[index] === "") { + const lastFilledIndex = otp.findIndex((value) => value === "") + setActiveInput(lastFilledIndex) + inputRefs.current[lastFilledIndex]?.select() + return + } else { + setActiveInput(index) + event.target.select() + } + onFocus?.(event) } - const handleBlur = () => { + const handleBlur = (event: React.FocusEvent) => { setActiveInput(activeInput - 1) + onBlur?.(event) } const handleKeyDown = (event: React.KeyboardEvent) => { @@ -106,7 +135,9 @@ export const OTPInput = ({ focusInput(activeInput - 1) } else if (event.code === "ArrowRight") { event.preventDefault() - focusInput(activeInput + 1) + if (otp[activeInput]) { + focusInput(activeInput + 1) + } } // React does not trigger onChange when the same value is entered // again. So we need to focus the next input manually in this case. @@ -123,6 +154,7 @@ export const OTPInput = ({ } else if (isInputNum && !isInputValueValid(event.key)) { event.preventDefault() } + onKeyDown?.(event) } const focusInput = (index: number) => { @@ -136,15 +168,21 @@ export const OTPInput = ({ } const changeCodeAtFocus = (value: string) => { - const otp = getOTPValue() - otp[activeInput] = value[0] - handleOTPChange(otp) + let otp = getOTPValue() + if (value === "") { + // If the value is empty, then move the values to the left (e.g Backspace is pressed) + let newOtp = otp.slice(0, activeInput).concat(otp.slice(activeInput + 1)) + newOtp = newOtp.concat(new Array(numInputs - newOtp.length).fill("")) + handleOTPChange(newOtp) + } else { + otp[activeInput] = value + handleOTPChange(otp) + } } const handleOTPChange = (otp: Array) => { - const otpValue = otp.join("") - setOTPValue(otpValue) - onChange?.(otpValue) + setOTPValue([...otp, ...new Array(numInputs - otp.length).fill("")]) + onChange?.(otp.join("")) } const handlePaste = (event: React.ClipboardEvent) => { @@ -174,13 +212,18 @@ export const OTPInput = ({ focusInput(nextActiveInput) handleOTPChange(otp) + onPaste?.(event) } return ( -
+
{Array.from({ length: numInputs }, (_, index) => index).map((i) => ( - {i < numInputs - 1 && (typeof renderSeparator === 'function' ? renderSeparator(i) : renderSeparator)} + {typeof renderSeparator === "function" + ? renderSeparator(i) + : i < numInputs - 1 && renderSeparator} ))} diff --git a/apps/www/registry/new-york/example/otp-input-controlled.tsx b/apps/www/registry/new-york/example/otp-input-controlled.tsx index 8f64a74c12b..58ccce40546 100644 --- a/apps/www/registry/new-york/example/otp-input-controlled.tsx +++ b/apps/www/registry/new-york/example/otp-input-controlled.tsx @@ -1,15 +1,16 @@ -import { OTPInput } from "@/registry/new-york/ui/otp-input"; -import * as React from "react"; +import * as React from "react" -export default function OtpInputDemo() { +import { OTPInput } from "@/registry/new-york/ui/otp-input" +export default function OtpInputDemo() { const [otp, setOtp] = React.useState("123456") return (

- OTP Value: {otp} + OTP Value: + {otp}

) -} \ No newline at end of file +} diff --git a/apps/www/registry/new-york/example/otp-input-custom-number.tsx b/apps/www/registry/new-york/example/otp-input-custom-number.tsx index 5b239321da2..d7b467cd1ad 100644 --- a/apps/www/registry/new-york/example/otp-input-custom-number.tsx +++ b/apps/www/registry/new-york/example/otp-input-custom-number.tsx @@ -1,5 +1,5 @@ -import { OTPInput } from "@/registry/new-york/ui/otp-input"; +import { OTPInput } from "@/registry/new-york/ui/otp-input" export default function OtpInputDemo() { return -} \ No newline at end of file +} diff --git a/apps/www/registry/new-york/example/otp-input-custom-style.tsx b/apps/www/registry/new-york/example/otp-input-custom-style.tsx new file mode 100644 index 00000000000..ad6b68e83e8 --- /dev/null +++ b/apps/www/registry/new-york/example/otp-input-custom-style.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/lib/utils" +import { OTPInput } from "@/registry/new-york/ui/otp-input" + +export default function OtpInputCustomStyleDemo() { + const roundedLeftByIndex = [0, 3] + const roundedRightByIndex = [2, 5] + + return ( +
+ + cn( + "relative w-14 h-14 text-[2rem]", + "flex items-center justify-center", + "rounded-none", + roundedLeftByIndex.includes(index) && + "rounded-l-md rounded-r-none", + roundedRightByIndex.includes(index) && + "rounded-r-md rounded-l-none" + ), + }} + renderSeparator={(index) => { + if (index === 2) { + return - + } + }} + placeholder="" + /> +
+ ) +} diff --git a/apps/www/registry/new-york/example/otp-input-demo.tsx b/apps/www/registry/new-york/example/otp-input-demo.tsx index 1b24f8a2710..87e0e50972b 100644 --- a/apps/www/registry/new-york/example/otp-input-demo.tsx +++ b/apps/www/registry/new-york/example/otp-input-demo.tsx @@ -1,5 +1,5 @@ -import { OTPInput } from "@/registry/new-york/ui/otp-input"; +import { OTPInput } from "@/registry/new-york/ui/otp-input" export default function OtpInputDemo() { return -} \ No newline at end of file +} diff --git a/apps/www/registry/new-york/example/otp-input-form.tsx b/apps/www/registry/new-york/example/otp-input-form.tsx index ff17de3ace5..7f0f176d6c7 100644 --- a/apps/www/registry/new-york/example/otp-input-form.tsx +++ b/apps/www/registry/new-york/example/otp-input-form.tsx @@ -14,8 +14,8 @@ import { FormLabel, FormMessage, } from "@/registry/new-york/ui/form" -import { toast } from "@/registry/new-york/ui/use-toast" import { OTPInput } from "@/registry/new-york/ui/otp-input" +import { toast } from "@/registry/new-york/ui/use-toast" const FormSchema = z.object({ otp: z.string().min(6, { diff --git a/apps/www/registry/new-york/example/otp-input-on-paste.tsx b/apps/www/registry/new-york/example/otp-input-on-paste.tsx index 375887116da..44f320a60bf 100644 --- a/apps/www/registry/new-york/example/otp-input-on-paste.tsx +++ b/apps/www/registry/new-york/example/otp-input-on-paste.tsx @@ -1,14 +1,14 @@ -import { OTPInput } from "@/registry/new-york/ui/otp-input"; +import { OTPInput } from "@/registry/new-york/ui/otp-input" export default function OtpInputDemo() { return ( - { e.preventDefault() const clipboardData = e.clipboardData const pastedData = clipboardData.getData("text") alert(`Pasted data: ${pastedData}`) - }} + }} /> ) -} \ No newline at end of file +} diff --git a/apps/www/registry/new-york/example/otp-input-placeholder.tsx b/apps/www/registry/new-york/example/otp-input-placeholder.tsx index cd812047558..3bda5b48c44 100644 --- a/apps/www/registry/new-york/example/otp-input-placeholder.tsx +++ b/apps/www/registry/new-york/example/otp-input-placeholder.tsx @@ -1,5 +1,5 @@ -import { OTPInput } from "@/registry/new-york/ui/otp-input"; +import { OTPInput } from "@/registry/new-york/ui/otp-input" export default function OtpInputDemo() { return -} \ No newline at end of file +} diff --git a/apps/www/registry/new-york/example/otp-input-separator.tsx b/apps/www/registry/new-york/example/otp-input-separator.tsx index 6c80e784384..c4293148433 100644 --- a/apps/www/registry/new-york/example/otp-input-separator.tsx +++ b/apps/www/registry/new-york/example/otp-input-separator.tsx @@ -1,5 +1,5 @@ -import { OTPInput } from "@/registry/new-york/ui/otp-input"; +import { OTPInput } from "@/registry/new-york/ui/otp-input" export default function OtpInputDemo() { return -} placeholder="" /> -} \ No newline at end of file +} diff --git a/apps/www/registry/new-york/ui/otp-input.tsx b/apps/www/registry/new-york/ui/otp-input.tsx index 5c00cac8bca..2d73b991845 100644 --- a/apps/www/registry/new-york/ui/otp-input.tsx +++ b/apps/www/registry/new-york/ui/otp-input.tsx @@ -3,56 +3,73 @@ import * as React from "react" import { cn } from "@/lib/utils" -import { Input } from "@/registry/new-york/ui/input"; +import { Input } from "@/registry/new-york/ui/input" -type AllowedInputTypes = 'password' | 'text' | 'number' | 'tel'; +type AllowedInputTypes = "password" | "text" | "number" | "tel" interface OTPInputProps extends Pick< React.InputHTMLAttributes, - | "onPaste" - | "pattern" - | "autoFocus" - | "className" - | "id" - | "name" + "pattern" | "autoFocus" | "id" | "name" > { /** Value of the OTP input */ value?: string /** Callback to be called when the OTP value changes */ onChange?: (otp: string) => void /** Callback to be called when pasting content into the component */ - onPaste?: (event: React.ClipboardEvent) => void; + onPaste?: (event: React.ClipboardEvent) => void + /** Callback to be called when the input is focused */ + onFocus?: (event: React.FocusEvent) => void + /** Callback to be called when the input is blurred */ + onBlur?: (event: React.FocusEvent) => void + /** Callback to be called when a key is pressed */ + onKeyDown?: (event: React.KeyboardEvent) => void + /** Callback to be called when the input value changes */ + onInput?: (event: React.FormEvent) => void /** Number of OTP inputs to be rendered */ numInputs?: number /** Placeholder for the inputs */ - placeholder?: string; - /** Type of the input */ + placeholder?: string + /** Type of the input */ type?: AllowedInputTypes /** Function to render the separator */ - renderSeparator?: ((index: number) => React.ReactNode) | React.ReactNode; + renderSeparator?: ((index: number) => React.ReactNode) | React.ReactNode + /** Additional styles for the component */ + styles?: { + container?: string + input?: string | ((index: number) => React.ReactNode) + } + /** Focus the last input */ + lastInputFocused?: boolean } export const OTPInput = ({ value = "", numInputs = 6, onChange, + onPaste, + onFocus, + onBlur, + onKeyDown, + onInput, type = "text", placeholder = "_", pattern = "[0-9]", autoFocus = false, - className, + lastInputFocused = false, + styles, id, - name, - onPaste, + name = "otp-input", renderSeparator, ...rest }: OTPInputProps) => { - const [otpValue, setOTPValue] = React.useState(value) + const [otpValue, setOTPValue] = React.useState( + new Array(numInputs).fill("").map((_, index) => value[index] ?? "") + ) const [activeInput, setActiveInput] = React.useState(0) const inputRefs = React.useRef>([]) - const getOTPValue = () => (otpValue ? otpValue.toString().split("") : []) + const getOTPValue = () => otpValue const isInputNum = type === "number" || type === "tel" @@ -63,8 +80,10 @@ export const OTPInput = ({ React.useEffect(() => { if (autoFocus) { inputRefs.current[0]?.focus() + } else if (lastInputFocused) { + focusInput(numInputs - 1) } - }, [autoFocus]) + }, [autoFocus, lastInputFocused]) const isInputValueValid = (value: string) => { const isTypeValid = isInputNum @@ -84,12 +103,22 @@ export const OTPInput = ({ const handleFocus = (event: React.FocusEvent) => (index: number) => { - setActiveInput(index) - event.target.select() + const otp = getOTPValue() + if (otp[index] === "") { + const lastFilledIndex = otp.findIndex((value) => value === "") + setActiveInput(lastFilledIndex) + inputRefs.current[lastFilledIndex]?.select() + return + } else { + setActiveInput(index) + event.target.select() + } + onFocus?.(event) } - const handleBlur = () => { + const handleBlur = (event: React.FocusEvent) => { setActiveInput(activeInput - 1) + onBlur?.(event) } const handleKeyDown = (event: React.KeyboardEvent) => { @@ -106,7 +135,9 @@ export const OTPInput = ({ focusInput(activeInput - 1) } else if (event.code === "ArrowRight") { event.preventDefault() - focusInput(activeInput + 1) + if (otp[activeInput]) { + focusInput(activeInput + 1) + } } // React does not trigger onChange when the same value is entered // again. So we need to focus the next input manually in this case. @@ -123,6 +154,7 @@ export const OTPInput = ({ } else if (isInputNum && !isInputValueValid(event.key)) { event.preventDefault() } + onKeyDown?.(event) } const focusInput = (index: number) => { @@ -136,15 +168,21 @@ export const OTPInput = ({ } const changeCodeAtFocus = (value: string) => { - const otp = getOTPValue() - otp[activeInput] = value[0] - handleOTPChange(otp) + let otp = getOTPValue() + if (value === "") { + // If the value is empty, then move the values to the left (e.g Backspace is pressed) + let newOtp = otp.slice(0, activeInput).concat(otp.slice(activeInput + 1)) + newOtp = newOtp.concat(new Array(numInputs - newOtp.length).fill("")) + handleOTPChange(newOtp) + } else { + otp[activeInput] = value + handleOTPChange(otp) + } } const handleOTPChange = (otp: Array) => { - const otpValue = otp.join("") - setOTPValue(otpValue) - onChange?.(otpValue) + setOTPValue([...otp, ...new Array(numInputs - otp.length).fill("")]) + onChange?.(otp.join("")) } const handlePaste = (event: React.ClipboardEvent) => { @@ -174,13 +212,18 @@ export const OTPInput = ({ focusInput(nextActiveInput) handleOTPChange(otp) + onPaste?.(event) } return ( -
+
{Array.from({ length: numInputs }, (_, index) => index).map((i) => ( - {i < numInputs - 1 && (typeof renderSeparator === 'function' ? renderSeparator(i) : renderSeparator)} + {typeof renderSeparator === "function" + ? renderSeparator(i) + : i < numInputs - 1 && renderSeparator} ))} diff --git a/apps/www/registry/registry.ts b/apps/www/registry/registry.ts index 9ef0301ef39..783f0ab7115 100644 --- a/apps/www/registry/registry.ts +++ b/apps/www/registry/registry.ts @@ -720,6 +720,12 @@ const example: Registry = [ registryDependencies: ["otp-input"], files: ["example/otp-input-form.tsx"], }, + { + name: "otp-input-custom-style", + type: "components:example", + registryDependencies: ["otp-input"], + files: ["example/otp-input-custom-style.tsx"], + }, { name: "pagination-demo", type: "components:example",