From ada3a24a7a0b08b8d59450e632a211b065f5f06e Mon Sep 17 00:00:00 2001 From: Anton Bochkovskyi <98012691+firehawk89@users.noreply.github.com> Date: Mon, 29 Jan 2024 11:24:29 +0200 Subject: [PATCH] Add OTP Input (#93) PR: https://github.com/dev-KPI/messenger-frontend/pull/93 --- project-words.txt | 1 + public/icons/change.svg | 3 - src/components/pages/auth/form-auth.tsx | 11 +- src/components/pages/auth/otp-input.tsx | 162 ++++++++++++++++++++++++ src/components/ui/input.tsx | 44 ++++--- src/components/ui/theme-toggler.tsx | 14 +- src/constants/auth.ts | 1 + src/constants/index.ts | 1 + src/utils/misc.ts | 4 + 9 files changed, 213 insertions(+), 28 deletions(-) delete mode 100644 public/icons/change.svg create mode 100644 src/components/pages/auth/otp-input.tsx create mode 100644 src/constants/auth.ts diff --git a/project-words.txt b/project-words.txt index 5697b73..fd18891 100644 --- a/project-words.txt +++ b/project-words.txt @@ -8,3 +8,4 @@ Roboto subheadline linecap linejoin +clsx diff --git a/public/icons/change.svg b/public/icons/change.svg deleted file mode 100644 index f9776e0..0000000 --- a/public/icons/change.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/components/pages/auth/form-auth.tsx b/src/components/pages/auth/form-auth.tsx index 9d85ea2..946df23 100644 --- a/src/components/pages/auth/form-auth.tsx +++ b/src/components/pages/auth/form-auth.tsx @@ -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 { @@ -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' @@ -25,6 +27,8 @@ const formSchema = z.object({ }) const AuthForm: FC = ({ className }) => { + const [otp, setOtp] = useState('') + const form = useForm>({ defaultValues: { agreement: false, @@ -33,8 +37,10 @@ const AuthForm: FC = ({ className }) => { resolver: zodResolver(formSchema), }) + const otpChange = (value: string) => setOtp(value.trim()) + function onSubmit(values: z.infer) { - alert(JSON.stringify(values)) + alert(JSON.stringify({ ...values, otp })) console.log(values) } @@ -59,6 +65,7 @@ const AuthForm: FC = ({ className }) => { )} /> + 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 = ({ + 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, + 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) => { + 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) => { + const { target } = e + const { previousElementSibling, value } = target + + if ( + previousElementSibling instanceof HTMLInputElement && + previousElementSibling.value === '' + ) { + return previousElementSibling.focus() + } + + target.setSelectionRange(0, value.length) + } + + return ( +
+ {otpDigits.map((digit, index) => ( + onChangeInternal(e, index)} + onFocus={handleFocus} + onKeyDown={handleKeyDown} + pattern="\d{1}" + type="text" + value={digit} + /> + ))} +
+ ) +} + +export default OtpInput diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 49268ed..17e3977 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -7,21 +7,35 @@ export interface InputProps const Input = React.forwardRef( ({ className, placeholder, type, ...props }, ref) => { return ( -
- - - {placeholder} - -
+ <> + {placeholder ? ( +
+ + + {placeholder} + +
+ ) : ( + + )} + ) } ) diff --git a/src/components/ui/theme-toggler.tsx b/src/components/ui/theme-toggler.tsx index 86340d4..269ffcd 100644 --- a/src/components/ui/theme-toggler.tsx +++ b/src/components/ui/theme-toggler.tsx @@ -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 ( @@ -28,11 +28,9 @@ export function ThemeToggler() { - setLightTheme}>Light - setDarkTheme}>Dark - setSystemTheme}> - System - + Light + Dark + System ) diff --git a/src/constants/auth.ts b/src/constants/auth.ts new file mode 100644 index 0000000..ff63853 --- /dev/null +++ b/src/constants/auth.ts @@ -0,0 +1 @@ +export const LENGTH_OTP = 5 diff --git a/src/constants/index.ts b/src/constants/index.ts index 94812ca..be93b81 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1 +1,2 @@ export * from './env' +export * from './auth' diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 7c99475..2633d1c 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -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)) +}