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
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
163 changes: 163 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,163 @@
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 = (
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 = 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}
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
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))
}
Loading