Skip to content

Commit

Permalink
Add OTP Input (#93)
Browse files Browse the repository at this point in the history
PR: #93
  • Loading branch information
firehawk89 authored Jan 29, 2024
1 parent 4e626d2 commit ada3a24
Show file tree
Hide file tree
Showing 9 changed files with 213 additions and 28 deletions.
1 change: 1 addition & 0 deletions project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ Roboto
subheadline
linecap
linejoin
clsx
3 changes: 0 additions & 3 deletions public/icons/change.svg

This file was deleted.

11 changes: 9 additions & 2 deletions src/components/pages/auth/form-auth.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import OtpInput from '@/components/pages/auth/otp-input'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Expand All @@ -11,9 +12,10 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { LENGTH_OTP } from '@/constants'
import { classnames } from '@/utils'
import { zodResolver } from '@hookform/resolvers/zod'
import { FC } from 'react'
import { FC, useState } from 'react'
import { useForm } from 'react-hook-form'
import * as z from 'zod'

Expand All @@ -25,6 +27,8 @@ const formSchema = z.object({
})

const AuthForm: FC<FormAuthProps> = ({ className }) => {
const [otp, setOtp] = useState<string>('')

const form = useForm<z.infer<typeof formSchema>>({
defaultValues: {
agreement: false,
Expand All @@ -33,8 +37,10 @@ const AuthForm: FC<FormAuthProps> = ({ className }) => {
resolver: zodResolver(formSchema),
})

const otpChange = (value: string) => setOtp(value.trim())

function onSubmit(values: z.infer<typeof formSchema>) {
alert(JSON.stringify(values))
alert(JSON.stringify({ ...values, otp }))
console.log(values)
}

Expand All @@ -59,6 +65,7 @@ const AuthForm: FC<FormAuthProps> = ({ className }) => {
</FormItem>
)}
/>
<OtpInput length={LENGTH_OTP} onChange={otpChange} otpValue={otp} />
<FormField
control={form.control}
name="agreement"
Expand Down
162 changes: 162 additions & 0 deletions src/components/pages/auth/otp-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { isNumeric } from '@/utils'
import { ChangeEvent, FC, FocusEvent, KeyboardEvent, useMemo } from 'react'

type OtpInputProps = {
className?: string
isError?: boolean
length: number
onChange: (value: string) => void
otpValue: string
}

const getOtpDigits = (otp: string, length: number) => {
const otpItems = otp.split('')
const digitsArray: string[] = []

for (let i = 0; i < length; i++) {
const element = otpItems[i]
const elementIsDigit = isNumeric(element)

digitsArray.push(elementIsDigit ? element : '')
}

return digitsArray
}

const OtpInput: FC<OtpInputProps> = ({
className,
isError,
length,
onChange,
otpValue,
}) => {
const otpDigits = useMemo(
() => getOtpDigits(otpValue, length),
[otpValue, length]
)

const focusNext = (target: HTMLInputElement) => {
const { nextElementSibling } = target
if (nextElementSibling instanceof HTMLInputElement) {
nextElementSibling.focus()
}
}

const focusPrev = (target: HTMLInputElement) => {
const { previousElementSibling } = target
if (previousElementSibling instanceof HTMLInputElement) {
previousElementSibling.focus()
}
}

const onChangeInternal = (
e: ChangeEvent<HTMLInputElement>,
index: number
) => {
const { target } = e
const { nextElementSibling } = target
let targetValue = target.value.trim()

const isTargetValueDigit = isNumeric(targetValue)

if (!isTargetValueDigit && targetValue !== '') {
return
}

if (
!isTargetValueDigit &&
nextElementSibling instanceof HTMLInputElement &&
nextElementSibling.value !== ''
) {
return
}

targetValue = isTargetValueDigit ? targetValue : ' '

const targetValueLength = targetValue.length

if (targetValueLength === 1) {
const newValue =
otpValue.substring(0, index) +
targetValue +
otpValue.substring(index + 1)

onChange(newValue)

if (!isTargetValueDigit) {
return
}

focusNext(target)
} else if (targetValueLength === length) {
onChange(targetValue)
target.blur()
}
}

const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const { key, target } = e

if (target instanceof HTMLInputElement) {
const { value } = target

if (key === 'ArrowRight' || key === 'ArrowDown') {
e.preventDefault()
return focusNext(target)
}

if (key === 'ArrowLeft' || key === 'ArrowUp') {
e.preventDefault()
return focusPrev(target)
}

target.setSelectionRange(0, value.length)

if (key !== 'Backspace' || value !== '') {
return
}

focusPrev(target)
}
}

const handleFocus = (e: FocusEvent<HTMLInputElement>) => {
const { target } = e
const { previousElementSibling, value } = target

if (
previousElementSibling instanceof HTMLInputElement &&
previousElementSibling.value === ''
) {
return previousElementSibling.focus()
}

target.setSelectionRange(0, value.length)
}

return (
<div className={cn('inline-flex gap-2', className)}>
{otpDigits.map((digit, index) => (
<Input
autoComplete="one-time-code"
className={cn(
'max-w-[3.5rem] text-center font-title py-5 text-[1.75rem] leading-[2.125rem] focus:border-bright-blue font-medium tracking-[0.0225rem]',
isError && 'text-bright-red'
)}
inputMode="numeric"
key={index + 1}
onChange={(e) => onChangeInternal(e, index)}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
pattern="\d{1}"
type="text"
value={digit}
/>
))}
</div>
)
}

export default OtpInput
44 changes: 29 additions & 15 deletions src/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,35 @@ export interface InputProps
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, placeholder, type, ...props }, ref) => {
return (
<div className="relative">
<input
className={cn(
'relative z-[5] peer pt-5 px-4 pb-2.5 w-full outline-none rounded-2xl border border-base-gray-3 focus:border-base-gray-5 dark:border-base-gray-7 bg-transparent placeholder:opacity-0 transition-colors',
className
)}
placeholder={placeholder}
ref={ref}
type={type}
{...props}
/>
<span className="absolute z-0 top-1/2 peer-focus:top-[0.375rem] left-4 -translate-y-1/2 peer-focus:translate-y-0 peer-focus:text-[0.6875rem] peer-focus:leading-[0.8125rem] text-base-gray-5 transition-all peer-[:not(:placeholder-shown)]:translate-y-0 peer-[:not(:placeholder-shown)]:top-[0.375rem] peer-[:not(:placeholder-shown)]:text-[0.6875rem] peer-[:not(:placeholder-shown)]:leading-[0.8125rem];">
{placeholder}
</span>
</div>
<>
{placeholder ? (
<div className="relative">
<input
className={cn(
'relative z-[5] peer pt-5 px-4 pb-2.5 w-full outline-none rounded-2xl border border-base-gray-3 focus:border-base-gray-5 dark:border-base-gray-7 bg-transparent placeholder:opacity-0 transition-colors',
className
)}
placeholder={placeholder}
ref={ref}
type={type}
{...props}
/>
<span className="absolute z-0 top-1/2 peer-focus:top-[0.375rem] left-4 -translate-y-1/2 peer-focus:translate-y-0 peer-focus:text-[0.6875rem] peer-focus:leading-[0.8125rem] text-base-gray-5 transition-all peer-[:not(:placeholder-shown)]:translate-y-0 peer-[:not(:placeholder-shown)]:top-[0.375rem] peer-[:not(:placeholder-shown)]:text-[0.6875rem] peer-[:not(:placeholder-shown)]:leading-[0.8125rem];">
{placeholder}
</span>
</div>
) : (
<input
className={cn(
'relative z-[5] peer pt-5 px-4 pb-2.5 w-full outline-none rounded-2xl border border-base-gray-3 focus:border-base-gray-5 dark:border-base-gray-7 bg-transparent placeholder:opacity-0 transition-colors',
className
)}
ref={ref}
type={type}
{...props}
/>
)}
</>
)
}
)
Expand Down
14 changes: 6 additions & 8 deletions src/components/ui/theme-toggler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import * as React from 'react'
export function ThemeToggler() {
const { setTheme } = useTheme()

const setLightTheme = () => setTheme('light')
const setDarkTheme = () => setTheme('dark')
const setSystemTheme = () => setTheme('system')
const setThemeLight = () => setTheme('light')
const setThemeDark = () => setTheme('dark')
const setThemeSystem = () => setTheme('system')

return (
<DropdownMenu>
Expand All @@ -28,11 +28,9 @@ export function ThemeToggler() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setLightTheme}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDarkTheme}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSystemTheme}>
System
</DropdownMenuItem>
<DropdownMenuItem onClick={setThemeLight}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={setThemeDark}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={setThemeSystem}>System</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
Expand Down
1 change: 1 addition & 0 deletions src/constants/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const LENGTH_OTP = 5
1 change: 1 addition & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './env'
export * from './auth'
4 changes: 4 additions & 0 deletions src/utils/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ export const getIconDimension = (size?: IconSize) => {
if (size) return ICON_SIZE[size]
return ICON_SIZE['md']
}

export const isNumeric = (value: string) => {
return !isNaN(parseInt(value, 10))
}

0 comments on commit ada3a24

Please sign in to comment.