회원가입 폼 구현 (Vanilla JS & React)

Vincent·2023년 5월 14일
1

요구사항

1. autofocus

페이지가 로드 된 시점에 ID 입력 창에 Focus가 되어 있어야 합니다.

2. 유효성 검사 로직

ID, 비밀번호, 비밀번호 확인 필드에 대한 유효성 검사를 수행해야 합니다.

유효성 검사 시점

  • input focus out 시 해당 input의 유효성을 검사합니다.
  • 가입하기 버튼을 눌렀을 때 모든 필드의 유효성을 검사합니다.

유효성 검사 패턴

  • 모든 필드의 값은 빠짐 없이 입력해야 합니다.

  • ID, 비밀번호, 비밀번호 확인 필드는 유효성 조건을 충족해야 합니다.

    	- ID: 5~20자. 영문 소문자, 숫자. 특수기호(_),(-)만 사용 가능
    	- 비밀번호: 8~16자. 영문 대/소문자, 숫자 사용 가능
    	- 비밀번호 확인: 비밀번호와 일치

3. 커스텀 에러 메세지

유효하지 않은 값일 경우, 각 경우에 맞는 에러 메시지를 보여주어야 합니다.

유효성 조건과 에러 메시지는 아래를 참고해주세요.

  • (공통) 빈 값일 경우: 필수 정보입니다.
  • [ID] 유효하지 않은 값일 경우: “5~20자의 영문 소문자, 숫자와 특수기호(_),(-)만 사용 가능합니다.”
  • [비밀번호] 유효하지 않은 값일 경우: “8~16자 영문 대 소문자, 숫자를 사용하세요.”
  • [비밀번호 확인] 유효하지 않은 값일 경우: “비밀번호가 일치하지 않습니다.”

4. 입력 확인 모달 창

가입하기 버튼 클릭 시, 모든 input의 값이 유효한 상태일 경우 입력한 아이디와 비밀번호를 확인할 수 있는 모달 창을 보여주어야 합니다.

  • "취소하기" 버튼 클릭 시 모달 창이 닫혀야 합니다.
  • "가입하기" 버튼 클릭 시 윈도우의 alert 창을 이용해 "가입되었습니다 🥳 " 라는 메시지를 출력해야 합니다.

5. 폰트 사이즈 조절 버튼

회원가입 폼에 사용된 기본 폰트 사이즈는 16px입니다.

기본 폰트 사이즈를 기준으로 1px씩 폰트 사이즈를 조절할 수 있는 기능을 구현해주세요.

(최소: 12px, 최대: 20px)

  • 현재 폰트 사이즈가 20px일 경우 + 버튼 비활성화
  • 현재 폰트 사이즈가 12px일 경우 - 버튼 비활성화

Vanilla JS

1. autofocus 구현

  • 변수명에 $ 붙이는 이유 : 코드를 읽는 사람들이 이 변수가 selector을 사용하여 가져온 DOM element 변수라는 것을 알 수 있게 하기 위한 관습
//대상 : ID입력 input
//이벤트 : 페이지(window)가 로드되었을 때
//핸들러 : focus()
const $id = document.getElementById('id')
const $idMsg = document.getElementById('id-msg')
window.addEventListener('load', () => $id.focus())
//input 태그에 바로 autofocus 속성 달아줘도 됨

2. 유효성 검사

정규식 표현 만드는 사이트(regexr)

  • 대상 : id, 비밀번호, 비밀번호 확인 input
  • 이벤트 : (1) input focus out, (2) 가입하기 버튼을 눌렀을 때
  • 핸들러 : (1) 해당 input의 유효성, (2) 모든 필드의 유효성 검사

3. 커스텀 에러 메세지

  • (1) 비어 있을 때
  • (2) 유효하지 않은 값일 때
    => input 태그에 border-red-600 class 추가 & **-msg div에 에러 메세지 추가

const $id = document.getElementById('id')
const $idMsg = document.getElementById('id-msg')

const $pw = document.getElementById('pw')
const $pwMsg = document.getElementById('pw-msg')

const $pwCheck = document.getElementById('pw-check')
const $pwCheckMsg = document.getElementById('pw-check-msg')

const ID_REGEX = new RegExp('^[a-z0-9_-]{5,20}$') //5~20자. 영문 소문자, 숫자. 특수기호(_),(-)만 사용 가능
const PW_REGEX = new RegExp('^[a-zA-Z0-9]{8,16}$') //8~16자. 영문 대/소문자, 숫자 사용 가능

const ID_ERROR_MSG = {
    required: '필수 정보입니다.',
    invalid: '5~20자의 영문 소문자, 숫자와 특수기호(_),(-)만 사용 가능합니다.',
}
const PW_ERROR_MSG = {
    required: '필수 정보입니다.',
    invalid: '8~16자 영문 대 소문자, 숫자를 사용하세요.',
}
const PW_CHECK_ERROR_MSG = {
    required: '필수 정보입니다.',
    invalid: '비밀번호가 일치하지 않습니다.',
}

로직은 id, password, password check 셋 다 동일. 따라서 id 코드 부분만 기재
password check는 정규식 테스트 대신 비밀번호와 일치 여부 확인하면 됨

const checkIdRegex = (value) => {
    if (value.length === 0) {
        return 'required'
    } else {
        return ID_REGEX.test(value) ? true : 'invalid'
    }
}
const checkIdValidation = (value) => {
    const isValidId = checkIdRegex(value)
    if (isValidId !== true) {
        //isValidId -> invalid, required
        $id.classList.add('border-red-600')
        $idMsg.innerText = ID_ERROR_MSG[isValidId]
    } else {
        $id.classList.remove('border-red-600')
        $idMsg.innerText = ''
    }
    return isValidId
}
$id.addEventListener('focusout', () => checkIdValidation($id.value))
// $id.addEventListener('focusout', (e) => checkIdValidation(e.target.value))

4. 입력 확인 모달 폼 구현

const $submit = document.getElementById('submit')
const $modal = document.getElementById('modal')

const $confirmId = document.getElementById('confirm-id')
const $confirmPw = document.getElementById('confirm-pw')

const $cancelBtn = document.getElementById('cancel-btn')
const $approveBtn = document.getElementById('approve-btn')

$submit.addEventListener('click', (e) => {
    //form 태그는 눌렸을 때 자동으로 form 내부의 값들을 서버로 전달함
    e.preventDefault()
    const isValidForm =
        checkIdValidation($id.value) === true &&
        checkPwValidation($pw.value) === true &&
        checkPwCheckValidation($pwCheck.value) === true
    if (isValidForm) {
        $confirmId.innerText = $id.value
        $confirmPw.innerText = $pw.value
        $modal.showModal()
    }
})

$cancelBtn.addEventListener('click', () => $modal.close())
$approveBtn.addEventListener('click', () => {
    window.alert('가입되었습니다.')
    $modal.close()
})

5. 폰트 사이즈 조절 버튼

const $increaseFontBtn = document.getElementById('increase-font-btn')
const $decreaseFontBtn = document.getElementById('decrease-font-btn')

const $html = document.documentElement

const MAX_FONT_SIZE = 20
const MIN_FONT_SIZE = 12

const getHtmlFontSize = () => {
  //html에 적용된 inline style or css sheet의 fontsize에서 숫자만 추출
    return parseFloat(window.getComputedStyle($html).fontSize)
}

$increaseFontBtn.addEventListener('click', () => {
    //font size + 1px
    onClickFontSizeControl('increase')
})
$decreaseFontBtn.addEventListener('click', () => {
    //font size - 1px
    onClickFontSizeControl('decrease')
})

const onClickFontSizeControl = (flag) => {
    const fontSize = getHtmlFontSize()
    let newFontSize = flag === 'increase' ? fontSize + 1 : fontSize - 1
    $html.style.fontSize = newFontSize
    $decreaseFontBtn.disabled = newFontSize <= MIN_FONT_SIZE
    $increaseFontBtn.disabled = newFontSize >= MAX_FONT_SIZE
}

Vanilla JS 리팩토링

유효성 검사와 커스텀 에러 메세지 출력 부분에 겹치는 로직들이 있음

  • 메세지 객체를 따로 구분하지 말고 하나로 묶음
  • 유효성 검사를 따로 구분하지 말고 하나로 묶음
  • 함수 호출 시 넘어가는 파라미터 수정
const ERROR_MSG = {
    required: '필수 정보입니다.',
    invalidId:
        '5~20자의 영문 소문자, 숫자와 특수기호(_),(-)만 사용 가능합니다.',
    invalidPw: '8~16자 영문 대 소문자, 숫자를 사용하세요.',
    invalidPwCheck: '비밀번호가 일치하지 않습니다.',
}
const checkRegex = (target) => {
    const { value, id } = target // 구조분해할당
    //const value = target.value; const id = target.id
    if (value.length === 0) {
        return 'required'
    } else {
        switch (id) {
            case 'id':
                return ID_REGEX.test(value) ? true : 'invalidId'
            case 'pw':
                return PW_REGEX.test(value) ? true : 'invalidPw'
            case 'pw-check':
                return value === $pw.value ? true : 'invalidPwCheck'
        }
    }
}
const checkValidation = (target, msgTarget) => {
    const isValid = checkRegex(target)
    if (isValid !== true) {
        target.classList.add('border-red-600')
        msgTarget.innerText = ERROR_MSG[isValid]
    } else {
        target.classList.remove('border-red-600')
        msgTarget.innerText = ''
    }
    return isValid
}
$id.addEventListener('focusout', () => checkValidation($id, $idMsg))

$pw.addEventListener('focusout', () => checkValidation($pw, $pwMsg))

$pwCheck.addEventListener('focusout', () =>
    checkValidation($pwCheck, $pwCheckMsg)
)

React

0. 컴포넌트화

전체 구조

1. autofocus 구현

  • useEffect, useRef(특정 DOM을 지정하여 해당 돔의 속성값을 파악하거나 속성값을 변경시키는 용도) 활용

2. 유효성 검사

useContext 활용하기

  • React에서만 사용할 수 있음 (리액트 내장 기능)
  • Entry 파일(root)에서 구성한 Provider를 내려 주는 형식.
  • context의 현재 값은 Hook을 호출하는 컴포넌트에 가장 가까이에 있는 <MyContext.Provider>의 value prop에 의해 결정
  • Provider로 감싸진 컴포넌트들에서는 useContext 사용 가능
  • focusout 될 때 검사 실행 -> onBlur 활용

3. 커스텀 에러 메세지

useState 활용하기

  1. errorData 객체와 setErrorData는 Form에서 관리 (각 FormInput에 prop으로 전달)
  2. input에서 onBlur 될 때 유효성 검사 실행
  3. 유효성 검사 결과로 setErrorData 이용해서 errorData 갱신
  4. ERROR_MSG에서 errorData[id]에 맞는 메세지 출력

4. 입력 확인 모달 창

useRef 사용하여 modal의 DOM element인 dialog에 접근. (showModal(), close())
그런데 리액트 함수형 컴포넌트는 ref를 바로 전달받을 수 없고 forwardRef로 감싸줘야지 두번쨰 인자로 ref를 받을 수 있다.

5. 폰트 사이즈 조절 버튼

최종 결과물

App.js
import './App.css'
import Form from './components/Form'
import Footer from './components/Footer'
import FormControlBox from './components/FontControlBox'
import Modal from './components/Modal'
import { createContext, useState, useRef } from 'react'

const initialFormData = {
    id: '',
    pw: '',
    confirmPw: '',
}
export const FormContext = createContext({
    formData: initialFormData,
    setFormData: () => {},
})

function App() {
    const [formData, setFormData] = useState(initialFormData)
    const modalRef = useRef(null)
    return (
        <FormContext.Provider value={{ formData, setFormData }}>
            <section className="form-wrapper">
                <Form modalRef={modalRef} />
                <Footer />
            </section>
            <FormControlBox />

            <Modal ref={modalRef} />
        </FormContext.Provider>
    )
}

export default App
Form.jsx
import { useState } from 'react'
import FormInput from './FormInput'

const initialErrorData = {
    id: '',
    pw: '',
    confirmPw: '',
}

const Form = ({ modalRef }) => {
    const [errorData, setErrorData] = useState(initialErrorData)
    const handleSubmit = (e) => {
        e.preventDefault()
        const isValid = Object.values(errorData).every(
            (value) => value === true
        ) //모든 value가 true이면 true

        isValid && modalRef.current.showModal()
    }
    return (
        <form
            id="form"
            className="w-full max-w-md m-auto bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
            autoComplete="off"
            onSubmit={handleSubmit}
        >
            <FormInput
                id={'id'}
                label={'아이디'}
                errorData={errorData}
                setErrorData={setErrorData}
                inputProps={{
                    type: 'text',
                    placeholder: '아이디를 입력해주세요.',
                    // autoFocus: true,
                }}
            />
            <FormInput
                id={'pw'}
                label={'비밀번호'}
                errorData={errorData}
                setErrorData={setErrorData}
                inputProps={{
                    type: 'password',
                    placeholder: '비밀번호를 입력해주세요.',
                    autoComplete: 'off',
                }}
            />
            <FormInput
                id={'confirmPw'}
                label={'비밀번호 확인'}
                errorData={errorData}
                setErrorData={setErrorData}
                inputProps={{
                    type: 'password',
                    placeholder: '비밀번호 확인을 입력해주세요.',
                    autoComplete: 'off',
                }}
            />
            <div className="flex items-center justify-center">
                <input
                    id="submit"
                    type="submit"
                    className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:bg-gray-500"
                    value="가입하기"
                />
            </div>
        </form>
    )
}
export default Form
FormInput.jsx
import { useEffect, useRef, useContext } from 'react'
import { FormContext } from '../App'

const ID_REGEX = new RegExp('^[a-z0-9_-]{5,20}$') //5~20자. 영문 소문자, 숫자. 특수기호(_),(-)만 사용 가능
const PW_REGEX = new RegExp('^[a-zA-Z0-9]{8,16}$') //8~16자. 영문 대/소문자, 숫자 사용 가능

const ERROR_MSG = {
    required: '필수 정보입니다.',
    invalidId:
        '5~20자의 영문 소문자, 숫자와 특수기호(_),(-)만 사용 가능합니다.',
    invalidPw: '8~16자 영문 대 소문자, 숫자를 사용하세요.',
    invalidConfirmPw: '비밀번호가 일치하지 않습니다.',
}

const FormInput = ({ id, label, inputProps, errorData, setErrorData }) => {
    const inputRef = useRef(null)
    const { formData, setFormData } = useContext(FormContext)

    const checkRegex = (inputId) => {
        let result
        const value = formData[inputId]
        if (value.length === 0) {
            result = 'required'
        } else {
            switch (inputId) {
                case 'id':
                    result = ID_REGEX.test(value) ? true : 'invalidId'
                    break
                case 'pw':
                    result = PW_REGEX.test(value) ? true : 'invalidPw'
                    checkRegex('confirmPw')
                    break
                case 'confirmPw':
                    result =
                        value === formData['pw'] ? true : 'invalidConfirmPw'
                    break
                default:
                    return
            }
        }
        //react에서는 setState를 비동기적으로 실행
        //직전의 최신 state값 보장하기 위한 방법 = 함수 넘겨주기
        setErrorData((prev) => ({ ...prev, [inputId]: result }))
    }
    useEffect(() => {
        if (id === 'id') {
            inputRef.current.focus()
        }
    }, []) //mount될 때만 실행

    return (
        <div className="mb-4">
            <label
                className="block text-gray-700 text-sm font-bold mb-2"
                htmlFor={id}
            >
                {label}
            </label>
            <input
                id={id}
                className="shadow border rounded w-full py-2 px-3 text-gray-700"
                ref={inputRef}
                value={formData[id]}
                onChange={(e) =>
                    //직전의 최신 state값 보장하기 위한 방법 = 함수 넘겨주기
                    setFormData((prev) => ({ ...prev, [id]: e.target.value }))
                }
                onBlur={() => checkRegex(id)}
                {...inputProps}
            />
            <div className="mt-1 mb-3 text-xs text-red-500">
                {errorData[id] !== true ? ERROR_MSG[errorData[id]] : ''}
            </div>
        </div>
    )
}

export default FormInput
FontControlBox.jsx
import { useEffect, useState } from 'react'

const $html = document.documentElement

const getHtmlFontSize = () => {
    //html에 적용된 inline style or css sheet의 fontsize에서 숫자만 추출
    return parseFloat(window.getComputedStyle($html).fontSize)
}

const MAX_FONT_SIZE = 20
const MIN_FONT_SIZE = 12

const FormControlBox = () => {
    const [fontSize, setFontSize] = useState(getHtmlFontSize())
    const handleFontSizeControl = (flag) => {
        if (flag === 'increase') {
            setFontSize((prev) => prev + 1)
        } else {
            setFontSize((prev) => prev - 1)
        }
    }

    useEffect(() => {
        $html.style.fontSize = fontSize + 'px'
    }, [fontSize])

    return (
        <aside id="font-control-box" className="flex fixed bottom-0 right-0">
            <button
                id="increase-font-btn"
                className="bg-white text-gray-500 border border-gray-300 hover:bg-red-50 focus:outline-none focus:shadow-outline disabled:bg-gray-500 disabled:text-white rounded-full"
                onClick={() => handleFontSizeControl('increase')}
                disabled = {fontSize >= MAX_FONT_SIZE}
            >
                +
            </button>
            <button
                id="decrease-font-btn"
                className="bg-white text-gray-500 border border-gray-300 hover:bg-blue-50 focus:outline-none focus:shadow-outline disabled:bg-gray-500 disabled:text-white rounded-full"
                onClick={() => handleFontSizeControl('decrease')}
                disabled = {fontSize <= MIN_FONT_SIZE}
            >
                -
            </button>
        </aside>
    )
}

export default FormControlBox

React 리팩토링

useContext (Context API)의 단점

  • Provider로 감싸진 내부에 있는 컴포넌트들은 context값이 업데이트 될 때마다 그와 관련없는, context값을 사용하고 있지 않은 컴포넌트라 할지라도 리렌더링된다. => redux, recoil 사용하는 이유
  • react-hook-form 사용하는 것도 하나의 방법
profile
Frontend & Artificial Intelligence

0개의 댓글