Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OTP Input #93

Merged
merged 15 commits into from
Jan 29, 2024
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
firehawk89 marked this conversation as resolved.
Show resolved Hide resolved
10 changes: 8 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 @@ -13,7 +14,7 @@ import {
import { Input } from '@/components/ui/input'
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 +26,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 +36,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 +64,7 @@ const AuthForm: FC<FormAuthProps> = ({ className }) => {
</FormItem>
)}
/>
<OtpInput length={5} onChange={otpChange} otpValue={otp} />
firehawk89 marked this conversation as resolved.
Show resolved Hide resolved
<FormField
control={form.control}
name="agreement"
Expand Down
160 changes: 160 additions & 0 deletions src/components/pages/auth/otp-input.tsx
firehawk89 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { Input } from '@/components/ui/input'
import { DIGIT_RE } from '@/constants'
import { cn } from '@/lib/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 = DIGIT_RE.test(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) {
;(nextElementSibling as HTMLInputElement).focus()
}
}

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

const onChangeInternal = (
firehawk89 marked this conversation as resolved.
Show resolved Hide resolved
e: ChangeEvent<HTMLInputElement>,
index: number
) => {
const { target } = e
const { nextElementSibling } = target
let targetValue = target.value.trim()

const isTargetValueDigit = DIGIT_RE.test(targetValue)

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

if (
!isTargetValueDigit &&
nextElementSibling &&
(nextElementSibling as HTMLInputElement).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
const { value } = target as HTMLInputElement

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

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

;(target as HTMLInputElement).setSelectionRange(0, value.length)

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

focusPrev(target as HTMLInputElement)
}

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

if (
previousElementSibling &&
(previousElementSibling as HTMLInputElement).value === ''
) {
return (previousElementSibling as HTMLInputElement).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}
maxLength={length}
firehawk89 marked this conversation as resolved.
Show resolved Hide resolved
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
8 changes: 3 additions & 5 deletions src/components/ui/theme-toggler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={setLightTheme}>Light</DropdownMenuItem>
firehawk89 marked this conversation as resolved.
Show resolved Hide resolved
<DropdownMenuItem onClick={setDarkTheme}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={setSystemTheme}>System</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
Expand Down
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 './regexp'
1 change: 1 addition & 0 deletions src/constants/regexp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DIGIT_RE = new RegExp(/^\d+$/)
firehawk89 marked this conversation as resolved.
Show resolved Hide resolved
Loading