로그인 UI 실습

#React #TailwindCSS

폼 다루기 챕터 마무리 실습으로 로그인 UI Validation 로직을 짜는 예제가 나왔다. 문제 자체는 UI는 제공하고 로직만 짜는 것으로 나왔는데, Frontend Mentor 프로젝트를 하며 Tailwind CSS에 좀 익숙해져서 UI 부터 로직까지 직접 한 번 짜 보았다. (제어 컴포넌트 방식 사용)

UI

내 코드

TailWind로 UI 구현하는건 크게 어려움이 없었다. 하나 어려웠던건 체크박스와 옆의 라벨 글자를 일직선 상에 배치하는 것이였는데, 글자를 span 태그로 한 번 감싸 부모 태그에 flex 속성을 주어 해결하였다.

          <label className="text-sm flex items-center">
            <input type='checkbox' className='mr-2' required></input>
            <span>
              I agree with <span className="font-bold">terms</span> and <span className="font-bold">policies</span>.
            </span>
          </label>

정답 코드와 비교

구현한 코드 자체는 비슷했지만, 내가 몰랐던 속성을 쓴 부분이 있어서 정리했다.

grid gap-4

grid gap-4를 사용하면 flex gap-4로 사용한 것 처럼 자식 간의 간격이 벌어진다. 하지만 grid 레이아웃이 되어서 flex 레이아웃보다는 좀 더 위아래 줄이 완벽하게 맞게된다고 한다. 이 부분은 나중에 다시 확인해볼 예정이다.

체크박스 체크 이미지 교체

정답 코드에서는 브라우저 기본 체크 UI가 아닌, 직접 등록한 이미지를 체크할 때 출력하도록 교체했다. 사용한 속성은 appearance-none, checked:bg-[url(’./check-icon.svg’)], checked:bg-no-repeat, checked: bg-center 이다.

appearance-none은 브라우저가 기본적으로 제공하는 운영체제 특유의 UI 스타일을 제거할 때 사용하는 속성이다.

**checked:**는 상태 변조자로, HTML 요소의 상태가 checked일 때만 뒤에 오는 스타일을 적용한다.

**bg-[url(’./check-icon.svg’)]**는 배경으로 url에 등록한 커스텀 아이콘을 출력하겠다는 의미이다.

bg-no-repeat은 아이콘이 바둑판처럼 반복되는 것을 막고, bg-center은 아이콘을 정중앙에 위치시킨다.

로직

내 코드

HTML 기본 Validation

import { useState } from "react";

export default function App() {
  const [ email, setEmail ] = useState('');
  const [ password, setPassword ] = useState('');

  const handleEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.currentTarget.value);
  }

  const handlePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPassword(e.currentTarget.value);
  }

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    alert('로그인에 성공했습니다.');
  }

  return(
    <div className="flex justify-center items-center min-h-screen">
      <div className="border rounded-md m-8 p-8 w-full max-w-sm space-y-4">
        <h1 className="font-bold font-xl">
          Login Into App
        </h1>
        <p className="text-xs">
          Please enter your details to continue.
        </p>
        <form onSubmit={handleSubmit} className="flex flex-col gap-4">
          <input value={email} onChange={handleEmail} type="email" required className="border rounded-md py-1 px-2 text-sm" placeholder="someone@example.com"></input>
          <input value={password} onChange={handlePassword} type="password" required className="border rounded-md py-1 px-2 text-sm" placeholder="Enter Password"></input>
          <label className="text-sm flex items-center">
            <input type='checkbox' className='mr-2' required></input>
            <span>
              I agree with <span className="font-bold">terms</span> and <span className="font-bold">policies</span>.
            </span>
          </label>
          <button type="submit" className="bg-black text-white rounded-md w-full py-3 text-sm">Log in</button>
        </form>
      </div>
    </div>
  );
}

첫번째로 구현한 방식은 HTML 기본 Validtion을 사용한 방법이였다. 기본적인 Validtion과 오류가 난 해당 필드 입력란으로 자동으로 커서가 옮겨지는 기능은 동작하지만, 실습 문제에서 요구한 경고창은 팝업되지 않았기 때문에 여기에서 디벨롭해서 커스텀 Validation으로 넘어갔다.

커스텀 Validation

import { useRef, useState } from "react";

interface ErrForm {
  email?: string;
  password?: string;
  checked?: string;
}

export default function App() {
  const [ loginInfo, setLoginInfo ] = useState({
    email: '',
    password: '',
  });
  const [ err, setErr ] = useState<ErrForm>({});

  const emailEl = useRef<HTMLInputElement>(null);
  const passwordEl = useRef<HTMLInputElement>(null);
  const checkedEl = useRef<HTMLInputElement>(null);

  const handleLogin = (e: React.ChangeEvent<HTMLInputElement>) => {
    setLoginInfo({
      ...loginInfo,
      [e.target.name]: e.target.value
    })
  }

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (validate()) {
      setTimeout(() => {
        alert('로그인에 성공했습니다.');
      }, 0);
    }
  }

  const validate = () => {
    const newErrors: ErrForm = {};

    if (loginInfo.email.trim() === '') {
      newErrors.email = '이메일을 입력하세요.';
    } else if (!loginInfo.email.includes('@')) {
      newErrors.email = '이메일 형식이 맞지 않습니다.';
    }

    if (loginInfo.password.trim() === '') {
      newErrors.password = '비밀번호를 입력하세요.';
    }

    if (!checkedEl.current?.checked) {
      newErrors.checked = '약관에 동의 해 주세요.';
    }

    setErr(newErrors);

    const isValid = Object.keys(newErrors).length === 0;

    if (!isValid) {
      if (newErrors.email) emailEl.current?.focus();
      else if (newErrors.password) passwordEl.current?.focus();
    }

    return isValid;
  };

  return(
    <div className="flex justify-center items-center min-h-screen">
      <div className="border rounded-md m-8 p-8 w-full max-w-sm space-y-4">
        <h1 className="font-bold font-xl">
          Login Into App
        </h1>
        <p className="text-xs">
          Please enter your details to continue.
        </p>
        <form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
          <input ref={emailEl} value={loginInfo.email} onChange={handleLogin} name="email" type="email" required className="border rounded-md py-1 px-2 text-sm" placeholder="someone@example.com"></input>
          { err.email ? <p className="text-red-500">{err.email}</p> : undefined }
          <input ref={passwordEl} value={loginInfo.password} onChange={handleLogin} name="password" type="password" required className="border rounded-md py-1 px-2 text-sm" placeholder="Enter Password"></input>
          { err.password ? <p className="text-red-500">{err.password}</p> : undefined }
          <label className="text-sm flex items-center">
            <input ref={checkedEl} type='checkbox' className='mr-2' required></input>
            <span>
              I agree with <span className="font-bold">terms</span> and <span className="font-bold">policies</span>.
            </span>
          </label>
          { err.checked ? <p className="text-red-500">{err.checked}</p> : undefined }
          <button type="submit" className="bg-black text-white rounded-md w-full py-3 text-sm">Log in</button>
        </form>
      </div>
    </div>
  );
}

구현할 때 경고창(alert)을 에러메시지로 순간 착각해서 에러메시지를 출력하는 코드를 짜버렸다… 하지만 에러메시지 출력하는 곳에 경고창 출력 로직을 추가하면 동작해서, 이렇게 마무리를 지었다.🙃

스스로 허들을 높인 탓인지 validate 로직을 짤 때 오류가 좀 있었다.

// 초기 코드
  const validate = () => {
    if (loginInfo.email.trim() === '') {
      setErr({
      	...err,
        email: '이메일을 입력하세요',
      });
      emailEl.current?.focus();
      return false;
    } else if (!loginInfo.email.includes('@')) {
      setErr({
      	...err,
        email: '이메일 형식이 맞지 않습니다.',
      });
      emailEl.current?.focus();
      return false;
    }
	...
    
    setErr({});
    return true;
  };

초기 코드에서는 상태가 명령하는 즉시 바뀐다고 착각을 했다. setErr를 실행하면 err 값이 즉시 바뀐다고 생각하여 …err로 이전 값을 계속 복사해서 사용했는데, useState는 비동기로 동작하여 setErr를 호출한다고 해서 그 즉시 err 변수가 업데이트되는 것이 아니라, 함수가 다 끝나고 다음 렌더링 때 반영이 된다. 이런 경우는 이전에 공부했던 것 처럼 매개변수를 넣은 함수 콜백 형태(setErr((prev)=>{})를 사용하면 정상적으로 동작한다.

하지만 이 로직의 경우는 빈 로컬 에러 변수를 하나 만들어 한 번에 파악하고 한 번에 반영하는 쪽이 더 로직이 깔끔해 다음과 같이 수정했다.

// 수정 코드
  const validate = () => {
    const newErrors: ErrForm = {};

    if (loginInfo.email.trim() === '') {
      newErrors.email = '이메일을 입력하세요.';
    } else if (!loginInfo.email.includes('@')) {
      newErrors.email = '이메일 형식이 맞지 않습니다.';
    }
	
    ...

    setErr(newErrors);

    const isValid = Object.keys(newErrors).length === 0;

    if (!isValid) {
      if (newErrors.email) emailEl.current?.focus();
      ...
    }

    return isValid;
  };

정답 코드와 비교

에러메시지 출력 부분 빼고는 정답 코드와 로직이 차이가 난 부분은 달리 없었다.