한글자씩 입력하는 코드입력 블럭 만들기

홍인열·2024년 2월 26일
0
post-thumbnail
post-custom-banner

인풋 창 N개를 이용한 N칸 코드입력 컴포넌트 구현

구현 기능

  1. 숫자만 입력가능
  2. 기본 입력 / 지우기(BackSpace)
    1. 기본입력시 자동으로 다음 인풋창으로 이동.
    2. 지우기시 지우고 자동 이전 인풋창으로 이동.
  3. 복사붙여넣기
    1. 커서를 기준으로 복사한 숫자가 나열됨.
    2. 복사된 숫자 앞부터 입력가능한 부분까지만 붙여넣기됨.
  4. 좌우 화살표 이동 & 문자 앞뒤 커서 위치에 따른 커서이동
    1. 화살표 이동시 커서 위치유지 된체로 이전/다음 인풋창으로 이동.
    2. 첫 인풋창은 커서사 왼쪽에 있을 경우 오른쪽이동시 커서위치 변경후 추가입력시 이동.
    3. 마지막 인풍창은 커서가 오른쪽에 있을 경우 왼쪽이동시 커서위치가 왼쪽으로 변경되고 추가 입력시 인풋창이동.
  5. 문자 앞뒤 커서 위치에 따른 덮어쓰기
    1. 커서위치가 숫자 앞이면 입력중인 인풋창 값이 변경됨
    2. 커서위치가 숫자 뒤면 다음 인풍창 값이 변경됨

작성코드

Input box component

  • 인풋 박스는 컴포넌트 화해서 사용, Array 로 감싸서 사용
import { useRef } from 'react';
import styled from '@emotion/styled';
import COLOR from '@/constants/COLOR';

//props
interface IInputBlock {
  className?: string;
  item: string;
  index: number; // 인풋창 위치 확인용
  codeArr: string[]; // 전체 입력코드확인
  disabled?: boolean;
  onChange: (code: string[]) => void; //coderArr 수정용
}

const InputBlock = (props: IInputBlock) => {
  const { className, item, index, codeArr, onChange, disabled } = props;
  
	const inputRef = useRef<HTMLInputElement>(null);

  const value = codeArr[index];

  const setValue = (value: string, position: number = 0) => {
    const nextCodeArr = [...codeArr];
    nextCodeArr[index + position] = value;
    onChange(nextCodeArr);
  };
	
	// 포커스된 인풋 전 인풋 ref
  const nextInput = inputRef.current?.nextElementSibling as HTMLInputElement;

	// 포커스된 인풋 다음 인풋 ref
  const previousInput = inputRef.current
    ?.previousElementSibling as HTMLInputElement;

	// 붙여넣기
  const _onPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
		//1. 클립보드에 있는 복사된 텍스트를 가져온다.
		//2. 텍스트가 number가 아니면 함수를 종료한다.
		//3. 커서위치와, 인풋 인덱스를 확인하여 code array 값을 변경한다.

    const clipboardData = e.clipboardData;
    const text = clipboardData.getData('text/plain');

    if (isNaN(Number(text))) return;

    const valueArr = text.split('');
    const nextCodeArr = [...codeArr];

    valueArr.map((e, i) => {
      const plusIndex = inputRef.current?.selectionStart === 0 ? 0 : 1;
      nextCodeArr.splice(index + i + plusIndex, 1, e);
    });
		
		
    onChange(nextCodeArr);
  };

	// 값입력, onChange 
  const _onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
		//1. 입력된 값이 number 가아니면 리턴한다.
		//2. 이미 입력된 값이 있을 경우 커서 위치 확인
		//3. 커서가 앞이면 해당 인풋창 값 변경
		//4. 커서가 뒤이면 다음 인풋창 값 변경 및 다음 인풋창으로 포커시 이동
		//5. 이미 입력된 값이 없으면 입력

    let value = e.target.value;

    if (isNaN(Number(value))) return;

    if (String(value).length > 1) {
      //커서 위치에 따른 분기처리
      if (nextInput && inputRef.current?.selectionStart === 2) {
        setValue(value[1], +1);
        nextInput.focus();
      }

      if (inputRef.current?.selectionStart === 1) {
        setValue(value[0]);
      }
      return;
    }

    setValue(value);
  };

	// 좌우 화살표 및 BackSpace 처리
  const _onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
	//1. 입력 키 확인
	//2. 이전/다음 인풋 확인
	//3. 커서위치 확인 
	//4. 해당 로직 실행

    if ((e.key === 'arrowRight' || e.key === 'ArrowRight') && nextInput) {
      if (
        // index === 0 &&
        inputRef.current?.selectionStart === 0 &&
        value !== ''
      ) {
        inputRef.current?.setSelectionRange(1, 1);
      } else {
        nextInput.focus();
      }
    }

    if ((e.key === 'arrowLeft' || e.key === 'ArrowLeft') && previousInput) {
      if (
        // codeArr.length - 1 === index &&
        inputRef.current?.selectionStart === 1
      ) {
        inputRef.current?.setSelectionRange(0, 0);
      } else {
        previousInput.focus();
      }
    }

    if (e.key === 'Backspace' && previousInput) {
      if (inputRef.current?.selectionStart === 1) {
        const nextCodeArr = [...codeArr];
        nextCodeArr[index] = '';
        onChange(nextCodeArr);
      } else {
        setValue('', -1);
        previousInput.focus();
      }
    }
  };

  return (
    <InputBlockStyle
      className={`${value.length > 0 ? 'focused' : ''} ${className ?? ''}`}
      type="text"
      ref={inputRef}
      value={value}
      onKeyDown={_onKeyDown}
      onChange={_onChange}
      onPaste={_onPaste}
      isInputValue={value.length > 0}
      inputMode="numeric"
    />
  );
};

interface InputBlockStyleProps {
  isInputValue: boolean;
}

export const InputBlockStyle = styled.input<InputBlockStyleProps>`
  width: 40px;
  height: 40px;
  border-radius: 8px;
  background-color: ${COLOR['GRAY6__#E7E7E7']};
  border: none;
  text-align: center;
  font-size: 20px;
  font-weight: 700;
  line-height: 140%; /* 33.6px */
  transition: 0.2s;
  :focus {
    outline: none;
  }
  &.focused {
    background-color: ${COLOR['WHITE__#FFFFFF']};
    border: 1px solid ${COLOR['BLACK__#000000']};
  }
  &.error {
    border: 1px solid ${COLOR['ERROR__#EC1A26']};
  }
`;

export default InputBlock;

사용하기

  • 인풋 박스는 그 부모 컴포넌트에서 index, code Arr과 onChange 함수를 전달 해주어 사용함.
  • codeArr 길이는 블럭 인풍창 개수가 되니 초기 값이 길이를 이용해 인풋창 개수 설정. ** 상태관리는 부모가해도되고, 필요해따르 전역으로 관리해도 무관.
import React, { useState } from 'react';
import InputBlock from './ModalBody.InputBlock';
(...)

export default function ModalBodyVerification() {
	
  const [codeArr, setCodeArr] = useState<string[]>(['', '', '', '', '', '']);
 
	(...)	

  const _onChangeCode = (code: string[]) => {
		// setCodeArr값은 codeArr.length로 잘라준다. 안그럼 인풋창 개수가 마음대로 변경됨..
    setCodeArr(code.slice(0, codeArr.length));
    
		//show error text
		//if (showCodeError === 'code' || showCodeError === 'invalidCode')
    //  setShowCodeError(null);
  };

  
  return (
    <ModalBodyVerificationStyle>

      (...)

      <div className="box">
	      (...)

        <div className="code__input">
          {codeArr.map((item, index) => (
            <InputBlock
              key={index}
              item={item}
              index={index}
              codeArr={codeArr}
              onChange={_onChangeCode}
              className={
                showCodeError === 'code' || showCodeError === 'invalidCode'
                  ? 'error'
                  : ''
              }
            />
          ))}
        </div>
      </div>

		 (...)

    </ModalBodyVerificationStyle>
  );
}

// style
(...)
.code__input {
        margin-top: 12px;
        display: flex;
        justify-content: center;
        gap: 8px;
      }

마치며

해당 인풋 박스를 만들고자했을때 별거 아니라고 생각했으나, 커서의 위치와 포커스 이동등 생각보다 고려할께 많았다. nextElementSibling, selectionStart 등 평소 사용할 기회가 없던 method를 사용해 볼 기회이기도해서 재밌었다. 정말 별에별 method가 다 있구나 싶다.

profile
함께 일하고싶은 개발자
post-custom-banner

0개의 댓글