Skip to content

Commit

Permalink
feat(ui): add input-otp component
Browse files Browse the repository at this point in the history
  • Loading branch information
tszhong0411 committed Jan 19, 2025
1 parent 3c2e54a commit 0729a10
Show file tree
Hide file tree
Showing 13 changed files with 326 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .changeset/loud-rats-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tszhong0411/tailwind-config': patch
'@tszhong0411/ui': patch
---

Add input-otp component
46 changes: 46 additions & 0 deletions apps/docs/src/app/ui/components/input-otp.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
title: Input OTP
description: Accessible one-time password component with copy paste functionality.
---

<ComponentPreview name='input-otp/input-otp' />

## Usage

```tsx
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@tszhong0411/ui'
```

```tsx
<InputOTP maxLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
```

## Examples

### Pattern

<ComponentPreview name='input-otp/pattern' />

### Separator

<ComponentPreview name='input-otp/separator' />

### Controlled

<ComponentPreview name='input-otp/controlled' />

### Form

<ComponentPreview name='input-otp/form' />
28 changes: 28 additions & 0 deletions apps/docs/src/components/demos/input-otp/controlled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client'

import { InputOTP, InputOTPGroup, InputOTPSlot } from '@tszhong0411/ui'
import { useState } from 'react'

const InputOTPControlledDemo = () => {
const [value, setValue] = useState('')

return (
<div className='space-y-2'>
<InputOTP maxLength={6} value={value} onChange={setValue}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<div className='text-center text-sm'>
{value === '' ? <>Enter your one-time password.</> : <>You entered: {value}</>}
</div>
</div>
)
}

export default InputOTPControlledDemo
80 changes: 80 additions & 0 deletions apps/docs/src/components/demos/input-otp/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use client'

import { zodResolver } from '@hookform/resolvers/zod'
import {
Button,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
InputOTP,
InputOTPGroup,
InputOTPSlot,
toast
} from '@tszhong0411/ui'
import { useForm } from 'react-hook-form'
import { z } from 'zod'

const FormSchema = z.object({
pin: z.string().min(6, {
message: 'Your one-time password must be 6 characters.'
})
})

const InputOTPFormDemo = () => {
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
pin: ''
}
})

const onSubmit = (data: z.infer<typeof FormSchema>) => {
toast('You submitted the following values:', {
description: (
<pre className='mt-2 w-[340px] rounded-md bg-zinc-950 p-4'>
<code className='text-white'>{JSON.stringify(data, null, 2)}</code>
</pre>
)
})
}

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='pin'
render={({ field }) => (
<FormItem>
<FormLabel>One-Time Password</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Please enter the one-time password sent to your phone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<Button type='submit'>Submit</Button>
</form>
</Form>
)
}

export default InputOTPFormDemo
21 changes: 21 additions & 0 deletions apps/docs/src/components/demos/input-otp/input-otp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@tszhong0411/ui'

const InputOTPDemo = () => {
return (
<InputOTP maxLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
)
}

export default InputOTPDemo
23 changes: 23 additions & 0 deletions apps/docs/src/components/demos/input-otp/pattern.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
REGEXP_ONLY_DIGITS_AND_CHARS
} from '@tszhong0411/ui'

const InputOTPPatternDemo = () => {
return (
<InputOTP maxLength={6} pattern={REGEXP_ONLY_DIGITS_AND_CHARS}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
)
}

export default InputOTPPatternDemo
24 changes: 24 additions & 0 deletions apps/docs/src/components/demos/input-otp/separator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@tszhong0411/ui'

const InputOTPSeparatorDemo = () => {
return (
<InputOTP maxLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
)
}

export default InputOTPSeparatorDemo
4 changes: 4 additions & 0 deletions apps/docs/src/config/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ const COMPONENT_LINKS = [
href: '/ui/components/hover-card',
text: 'Hover Card'
},
{
href: '/ui/components/input-otp',
text: 'Input OTP'
},
{
href: '/ui/components/input',
text: 'Input'
Expand Down
5 changes: 5 additions & 0 deletions packages/tailwind-config/tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ const config: Partial<Config> = {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' }
},
'caret-blink': {
'0%, 70%, 100%': { opacity: '1' },
'20%, 50%': { opacity: '0' }
},
'marquee-left': {
from: { transform: 'translateX(0)' },
to: { transform: 'translateX(calc(-100% - var(--gap)))' }
Expand All @@ -83,6 +87,7 @@ const config: Partial<Config> = {
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'caret-blink': 'caret-blink 1.25s ease-out infinite',
'marquee-left': 'marquee-left var(--duration, 30s) linear infinite',
'marquee-up': 'marquee-up var(--duration, 30s) linear infinite'
},
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"cmdk": "^1.0.4",
"embla-carousel-react": "^8.5.2",
"framer-motion": "12.0.0-alpha.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.469.0",
"merge-refs": "^1.3.0",
"react-hook-form": "^7.54.2",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export * from './files'
export * from './form'
export * from './hover-card'
export * from './input'
export * from './input-otp'
export * from './kbd'
export * from './label'
export * from './link'
Expand Down
73 changes: 73 additions & 0 deletions packages/ui/src/input-otp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client'

import { cn } from '@tszhong0411/utils'
import { OTPInput, OTPInputContext } from 'input-otp'
import { MinusIcon } from 'lucide-react'
import { useContext } from 'react'

type InputOTPProps = React.ComponentProps<typeof OTPInput>

const InputOTP = (props: InputOTPProps) => {
const { className, containerClassName, ...rest } = props

return (
<OTPInput
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName
)}
className={cn('disabled:cursor-not-allowed', className)}
{...rest}
/>
)
}

type InputOTPGroupProps = React.ComponentProps<'div'>

const InputOTPGroup = (props: InputOTPGroupProps) => {
const { className, ...rest } = props

return <div className={cn('flex items-center', className)} {...rest} />
}

type InputOTPSlotProps = React.ComponentProps<'div'> & { index: number }

const InputOTPSlot = (props: InputOTPSlotProps) => {
const { index, className, ...rest } = props

const inputOTPContext = useContext(OTPInputContext)

const slot = inputOTPContext.slots[index]
const { char, hasFakeCaret, isActive } = slot ?? {}

return (
<div
className={cn(
'border-input relative flex size-9 items-center justify-center border-y border-r shadow-sm transition-all',
'first:rounded-l-md first:border-l',
'last:rounded-r-md',
isActive && 'ring-ring z-10 ring-1',
className
)}
{...rest}
>
{char}
{hasFakeCaret && (
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<div className='animate-caret-blink bg-foreground h-4 w-px duration-1000' />
</div>
)}
</div>
)
}

type InputOTPSeparatorProps = React.ComponentProps<'div'>

const InputOTPSeparator = (props: InputOTPSeparatorProps) => (
<div role='separator' {...props}>
<MinusIcon />
</div>
)

export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot }
export { REGEXP_ONLY_CHARS, REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp'
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 0729a10

Please sign in to comment.