|
| 1 | +import { useEffect, useRef, useState } from 'react'; |
| 2 | +import type { ChangeEvent, ClipboardEvent, KeyboardEvent } from 'react'; |
| 3 | + |
| 4 | +import type { Promisable } from 'type-fest'; |
| 5 | + |
| 6 | +import { useNotificationsStore, useTranslation } from '@/hooks'; |
| 7 | +import { cn } from '@/utils'; |
| 8 | + |
| 9 | +const CODE_LENGTH = 6; |
| 10 | + |
| 11 | +const EMPTY_CODE = Object.freeze(Array<null>(CODE_LENGTH).fill(null)); |
| 12 | + |
| 13 | +type OneTimePasswordInputProps = { |
| 14 | + [key: `data-${string}`]: unknown; |
| 15 | + className?: string; |
| 16 | + onComplete: (code: number) => Promisable<void>; |
| 17 | +}; |
| 18 | + |
| 19 | +function getUpdatedDigits(digits: (null | number)[], index: number, value: null | number) { |
| 20 | + const updatedDigits = [...digits]; |
| 21 | + updatedDigits[index] = value; |
| 22 | + return updatedDigits; |
| 23 | +} |
| 24 | + |
| 25 | +export const OneTimePasswordInput = ({ className, onComplete, ...props }: OneTimePasswordInputProps) => { |
| 26 | + const notifications = useNotificationsStore(); |
| 27 | + const { t } = useTranslation('libui'); |
| 28 | + const [digits, setDigits] = useState<(null | number)[]>([...EMPTY_CODE]); |
| 29 | + const inputRefs = digits.map(() => useRef<HTMLInputElement>(null)); |
| 30 | + |
| 31 | + useEffect(() => { |
| 32 | + const isComplete = digits.every((value) => Number.isInteger(value)); |
| 33 | + if (isComplete) { |
| 34 | + void onComplete(parseInt(digits.join(''))); |
| 35 | + setDigits([...EMPTY_CODE]); |
| 36 | + } |
| 37 | + }, [digits]); |
| 38 | + |
| 39 | + const focusNext = (index: number) => inputRefs[index + 1 === digits.length ? 0 : index + 1]?.current?.focus(); |
| 40 | + |
| 41 | + const focusPrev = (index: number) => inputRefs[index - 1 >= 0 ? index - 1 : digits.length - 1]?.current?.focus(); |
| 42 | + |
| 43 | + const handleChange = (e: ChangeEvent<HTMLInputElement>, index: number) => { |
| 44 | + let value: null | number; |
| 45 | + if (e.target.value === '') { |
| 46 | + value = null; |
| 47 | + } else if (Number.isInteger(parseInt(e.target.value))) { |
| 48 | + value = parseInt(e.target.value); |
| 49 | + } else { |
| 50 | + return; |
| 51 | + } |
| 52 | + setDigits((prevDigits) => getUpdatedDigits(prevDigits, index, value)); |
| 53 | + focusNext(index); |
| 54 | + }; |
| 55 | + |
| 56 | + const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>, index: number) => { |
| 57 | + switch (e.key) { |
| 58 | + case 'ArrowLeft': |
| 59 | + focusPrev(index); |
| 60 | + break; |
| 61 | + case 'ArrowRight': |
| 62 | + focusNext(index); |
| 63 | + break; |
| 64 | + case 'Backspace': |
| 65 | + setDigits((prevDigits) => getUpdatedDigits(prevDigits, index - 1, null)); |
| 66 | + focusPrev(index); |
| 67 | + } |
| 68 | + }; |
| 69 | + |
| 70 | + const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => { |
| 71 | + e.preventDefault(); |
| 72 | + const pastedDigits = e.clipboardData |
| 73 | + .getData('text/plain') |
| 74 | + .split('') |
| 75 | + .slice(0, CODE_LENGTH) |
| 76 | + .map((value) => parseInt(value)); |
| 77 | + const isValid = pastedDigits.length === CODE_LENGTH && pastedDigits.every((value) => Number.isInteger(value)); |
| 78 | + if (isValid) { |
| 79 | + setDigits(pastedDigits); |
| 80 | + } else { |
| 81 | + notifications.addNotification({ |
| 82 | + message: t('oneTimePasswordInput.invalidCodeFormat'), |
| 83 | + type: 'warning' |
| 84 | + }); |
| 85 | + } |
| 86 | + }; |
| 87 | + |
| 88 | + return ( |
| 89 | + <div className={cn('flex gap-2', className)} {...props}> |
| 90 | + {digits.map((_, index) => ( |
| 91 | + <input |
| 92 | + className="w-1/6 rounded-md border border-slate-300 bg-transparent p-2 shadow-xs hover:border-slate-300 focus:border-sky-800 focus:outline-hidden dark:border-slate-600 dark:hover:border-slate-400 dark:focus:border-sky-500" |
| 93 | + key={index} |
| 94 | + maxLength={1} |
| 95 | + ref={inputRefs[index]} |
| 96 | + type="text" |
| 97 | + value={digits[index] ?? ''} |
| 98 | + onChange={(e) => { |
| 99 | + handleChange(e, index); |
| 100 | + }} |
| 101 | + onKeyDown={(e) => { |
| 102 | + handleKeyDown(e, index); |
| 103 | + }} |
| 104 | + onPaste={handlePaste} |
| 105 | + /> |
| 106 | + ))} |
| 107 | + </div> |
| 108 | + ); |
| 109 | +}; |
0 commit comments