|
import { ChangeEvent, FormEvent, ClipboardEvent, KeyboardEvent, FocusEvent } from "react" |
|
import { useEffect, useState, useRef, useReducer, Reducer } from "react" |
|
|
|
function submitValues(values: number[]) { |
|
return new Promise(resolve => { |
|
setTimeout(() => { |
|
resolve(values) |
|
}, 1000) |
|
}) |
|
} |
|
|
|
function clampIndex(index: number) { |
|
if (index > 3) { |
|
return 3 |
|
} else if (index < 0) { |
|
return 0 |
|
} else { |
|
return index |
|
} |
|
} |
|
|
|
|
|
enum STATUS { |
|
IDLE, |
|
PENDING, |
|
} |
|
|
|
type ActionType = |
|
| { type: "INPUT", payload: { index: number, value: number | string } } |
|
| { type: "BACK" } |
|
| { type: "FOCUS", payload: { focusedIndex: number } } |
|
| { type: "PASTE", payload: { pastedValue: number[] } } |
|
| { type: "VERIFY", } |
|
| { type: "VERIFY_SUCCESS" } |
|
|
|
interface InitialState { |
|
inputValues: (number | '')[] |
|
status: STATUS, |
|
focusedIndex: number |
|
} |
|
|
|
function reducer(state: InitialState, action: ActionType) { |
|
switch (action.type) { |
|
case "INPUT": |
|
return { |
|
...state, |
|
inputValues: [ |
|
...state.inputValues.slice(0, action.payload.index), |
|
action.payload.value, |
|
...state.inputValues.slice(action.payload.index + 1) |
|
] as number[], |
|
focusedIndex: clampIndex(action.payload.index + 1) |
|
} |
|
case "BACK": |
|
return { |
|
...state, |
|
focusedIndex: clampIndex(state.focusedIndex - 1) |
|
} |
|
case "PASTE": |
|
return { |
|
...state, |
|
inputValues: state.inputValues.map((_, index) => action.payload.pastedValue[index] || '') |
|
} |
|
case "FOCUS": |
|
return { |
|
...state, |
|
focusedIndex: action.payload.focusedIndex |
|
} |
|
case "VERIFY": |
|
return { |
|
...state, |
|
status: STATUS.PENDING |
|
} |
|
case "VERIFY_SUCCESS": |
|
return { |
|
...state, |
|
status: STATUS.IDLE |
|
} |
|
default: |
|
throw new Error("unknown action") |
|
} |
|
} |
|
|
|
const initialState = { |
|
inputValues: Array(3).fill(''), |
|
focusedIndex: 0, |
|
status: STATUS.IDLE |
|
} |
|
|
|
const TwoFactorInput = () => { |
|
const [{ inputValues, focusedIndex, status }, dispatch] = useReducer<Reducer<InitialState, ActionType>>(reducer, initialState) |
|
|
|
function handleInput(index: number, value: number | string) { |
|
dispatch({ type: 'INPUT', payload: { index, value } }) |
|
} |
|
|
|
function handleBack() { |
|
dispatch({ type: 'BACK' }) |
|
} |
|
|
|
function handlePaste(pastedValue: number[]) { |
|
dispatch({ type: 'PASTE', payload: { pastedValue } }) |
|
if (pastedValue.length === 3) { |
|
dispatch({ type: 'VERIFY' }) |
|
submitValues(pastedValue).then(() => { |
|
dispatch({ type: 'VERIFY_SUCCESS' }) |
|
}) |
|
} |
|
} |
|
|
|
function handleFocus(focusedIndex: number) { |
|
dispatch({ type: 'FOCUS', payload: { focusedIndex } }) |
|
} |
|
|
|
function handleSubmit(e: FormEvent<HTMLFormElement>) { |
|
e.preventDefault() |
|
dispatch({ type: 'VERIFY' }) |
|
submitValues(inputValues as number[]).then(() => { |
|
dispatch({ type: 'VERIFY_SUCCESS' }) |
|
}) |
|
} |
|
|
|
return ( |
|
<form className="flex gap-4" onSubmit={handleSubmit}> |
|
{inputValues.map((value, index) => ( |
|
<Input |
|
key={index} |
|
value={value} |
|
onChange={handleInput} |
|
onPaste={handlePaste} |
|
onFocus={handleFocus} |
|
onBackspace={handleBack} |
|
isFocused={focusedIndex === index} |
|
index={index} |
|
isDisabled={status === STATUS.PENDING} |
|
/> |
|
))} |
|
</form> |
|
) |
|
} |
|
|
|
interface InputProps { |
|
index: number, |
|
value: number | '', |
|
onChange: (index: number, value: number | string) => void, |
|
onPaste: (pastedValue: number[]) => void |
|
onBackspace: () => void |
|
isFocused: boolean |
|
onFocus: (index: number) => void |
|
isDisabled: boolean |
|
} |
|
|
|
const Input = (props: InputProps) => { |
|
const { index, value, onChange, onPaste, onBackspace, isFocused, onFocus, isDisabled, ...rest } = props |
|
const inputRef = useRef<HTMLInputElement | null>(null) |
|
|
|
useEffect(() => { |
|
requestAnimationFrame(() => { |
|
if (inputRef.current !== document.activeElement && isFocused) { |
|
inputRef.current?.focus() |
|
} |
|
}) |
|
}, [inputRef, isFocused]) |
|
|
|
function handleChange(e: ChangeEvent<HTMLInputElement>) { |
|
onChange(index, e.target.value) |
|
} |
|
|
|
function handlePaste(e: ClipboardEvent<HTMLInputElement>) { |
|
onPaste(e.clipboardData.getData('text').split('').map(Number)) |
|
} |
|
|
|
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) { |
|
if (e.key === 'Backspace') { |
|
onBackspace() |
|
} |
|
} |
|
|
|
function handleFocus(e: FocusEvent<HTMLInputElement>) { |
|
e.target.setSelectionRange(0, 1) |
|
onFocus(index) |
|
} |
|
|
|
return ( |
|
<input |
|
ref={inputRef} |
|
type="text" |
|
value={value} |
|
onChange={handleChange} |
|
onFocus={handleFocus} |
|
onKeyDown={handleKeyDown} |
|
onPaste={handlePaste} |
|
maxLength={1} |
|
disabled={isDisabled} |
|
{...rest} |
|
className="w-12 h-12 text-s-grey-17 text-lg text-center border-2 border-s-grey-16 rounded focus:outline-none focus:border-blue-500" |
|
/> |
|
) |
|
} |
|
|
|
export { TwoFactorInput } |