diff --git a/packages/web/package.json b/packages/web/package.json index 14e8f95..65de8c5 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@emotion/cache": "11.11.0", + "@emotion/is-prop-valid": "^1.3.1", "@emotion/react": "11.11.4", "@emotion/styled": "11.11.0", "@mui/icons-material": "5.15.12", @@ -29,6 +30,7 @@ "next": "14.1.3", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.53.0", "styled-components": "^6.1.8", "zod": "^3.21.4", "zustand": "^4.5.2" diff --git a/packages/web/src/common/components/Forms/PhoneInput.tsx b/packages/web/src/common/components/Forms/PhoneInput.tsx index e4b4806..c4c1e25 100644 --- a/packages/web/src/common/components/Forms/PhoneInput.tsx +++ b/packages/web/src/common/components/Forms/PhoneInput.tsx @@ -1,33 +1,131 @@ -import React, { ChangeEvent, useEffect, useState } from "react"; -import TextInput, { - TextInputProps, -} from "@sparcs-students/web/common/components/Forms/TextInput"; - -interface PhoneInputProps extends Omit { - value: string; - onChange: (value: string) => void; +/* +--- 사용 방법 (예시) --- + +*/ + +import React, { + ChangeEvent, + ChangeEventHandler, + InputHTMLAttributes, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +import isPropValid from "@emotion/is-prop-valid"; +import styled, { css } from "styled-components"; + +import ErrorMessage from "./_atomic/ErrorMessage"; +import Label from "./_atomic/Label"; + +export interface PhoneInputProps + extends InputHTMLAttributes { + label?: string; + placeholder: string; + disabled?: boolean; + value?: string; + handleChange?: (value: string) => void; + setErrorStatus?: (hasError: boolean) => void; + onChange?: ChangeEventHandler; } -const PhoneInput: React.FC = ({ +const errorBorderStyle = css` + border-color: ${({ theme }) => theme.colors.RED[600]}; +`; + +const disabledStyle = css` + background-color: ${({ theme }) => theme.colors.GRAY[100]}; + border-color: ${({ theme }) => theme.colors.GRAY[200]}; +`; + +const Input = styled.input.withConfig({ + shouldForwardProp: prop => isPropValid(prop) && prop !== "hasError", +})` + display: block; + width: 100%; + padding: 8px 12px 8px 12px; + outline: none; + border: 1px solid ${({ theme }) => theme.colors.GRAY[200]}; + border-radius: 4px; + gap: 8px; + font-family: ${({ theme }) => theme.fonts.FAMILY.PRETENDARD}; + font-size: 16px; + line-height: 20px; + font-weight: ${({ theme }) => theme.fonts.WEIGHT.REGULAR}; + color: ${({ theme }) => theme.colors.BLACK}; + background-color: ${({ theme }) => theme.colors.WHITE}; + &:focus { + border-color: ${({ theme, hasError, disabled }) => + !hasError && !disabled && theme.colors.PRIMARY}; + } + &:hover:not(:focus) { + border-color: ${({ theme, hasError, disabled }) => + !hasError && !disabled && theme.colors.GRAY[300]}; + } + &::placeholder { + color: ${({ theme }) => theme.colors.GRAY[200]}; + } + ${({ disabled }) => disabled && disabledStyle} + ${({ hasError }) => hasError && errorBorderStyle} +`; + +const InputWrapper = styled.div` + width: 100%; + flex-direction: column; + display: flex; + gap: 4px; +`; + +// Component +const PhoneInput: React.FC = ({ label = "", + placeholder, + disabled = false, value = "", - onChange = () => {}, + handleChange = () => {}, // setValue + setErrorStatus = () => {}, + onChange = undefined, // display results (complicated) + optional = false, ...props }) => { const [error, setError] = useState(""); const [touched, setTouched] = useState(false); + const inputRef = useRef(null); + const cursorRef = useRef(0); + + useLayoutEffect(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(cursorRef.current, cursorRef.current); + } + }, [value]); + + useEffect(() => { + const hasError = !!error; + if (setErrorStatus) { + setErrorStatus(hasError); + } + }, [error, setErrorStatus]); useEffect(() => { if (touched) { const isValidFormat = /^(\d{3}-\d{4}-\d{4})$/.test(value) || /^\d*$/.test(value.replace(/-/g, "")); - - if (!value) { + if (!optional && !value) { setError("필수로 채워야 하는 항목입니다"); } else if (!isValidFormat) { setError("숫자만 입력 가능합니다"); - } else if (value.replace(/-/g, "").length !== 11) { + } else if ( + value.replace(/-/g, "").length !== 11 || + value.slice(0, 3) !== "010" + ) { setError("유효하지 않은 전화번호입니다"); } else { setError(""); @@ -39,20 +137,9 @@ const PhoneInput: React.FC = ({ setTouched(true); }; - const handleChange = ( - e: ChangeEvent, - ) => { - const inputValue = e.target.value; - - if (inputValue.length <= 13) { - onChange(inputValue); - } - }; - const formatValue = (nums: string) => { const digits = nums.replace(/\D/g, ""); let formattedInput = ""; - if (digits.length <= 3) { formattedInput = digits; } else if (digits.length <= 7) { @@ -60,21 +147,51 @@ const PhoneInput: React.FC = ({ } else if (digits.length <= 11) { formattedInput = `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`; } - return formattedInput; }; + const handlePhoneValueChange = (e: ChangeEvent) => { + const inputValue = e.target.value; + const currentCursor = inputRef.current?.selectionStart || 0; + const lengthDifference = inputValue.length - formatValue(value).length; + if ( + lengthDifference > 0 && + (currentCursor === 3 || + currentCursor === 4 || + currentCursor === 8 || + currentCursor === 9) + ) { + cursorRef.current = currentCursor + 1; + } else if ( + lengthDifference < 0 && + ((currentCursor === 4 && inputValue.length === 4) || + (currentCursor === 9 && inputValue.length === 9)) + ) { + cursorRef.current = currentCursor - 1; + } else { + cursorRef.current = currentCursor; + } + if (inputValue.length <= 13) handleChange(inputValue); + }; + return ( - + + {label && } + + + {error && {error}} + + ); }; -// TODO: DB 값 default로 넣어두기 export default PhoneInput; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be2573a..62d22f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,9 @@ importers: '@emotion/cache': specifier: 11.11.0 version: 11.11.0 + '@emotion/is-prop-valid': + specifier: ^1.3.1 + version: 1.3.1 '@emotion/react': specifier: 11.11.4 version: 11.11.4(@types/react@18.2.64)(react@18.2.0) @@ -235,6 +238,9 @@ importers: react-dom: specifier: ^18 version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.53.0 + version: 7.53.0(react@18.2.0) styled-components: specifier: ^6.1.8 version: 6.1.8(react-dom@18.2.0)(react@18.2.0) @@ -893,6 +899,12 @@ packages: '@emotion/memoize': 0.8.1 dev: false + /@emotion/is-prop-valid@1.3.1: + resolution: {integrity: sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==} + dependencies: + '@emotion/memoize': 0.9.0 + dev: false + /@emotion/memoize@0.7.4: resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} requiresBuild: true @@ -903,6 +915,10 @@ packages: resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==} dev: false + /@emotion/memoize@0.9.0: + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + dev: false + /@emotion/react@11.11.4(@types/react@18.2.64)(react@18.2.0): resolution: {integrity: sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==} peerDependencies: @@ -950,7 +966,7 @@ packages: dependencies: '@babel/runtime': 7.24.0 '@emotion/babel-plugin': 11.11.0 - '@emotion/is-prop-valid': 1.2.1 + '@emotion/is-prop-valid': 1.3.1 '@emotion/react': 11.11.4(@types/react@18.2.64)(react@18.2.0) '@emotion/serialize': 1.1.3 '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) @@ -7597,6 +7613,15 @@ packages: scheduler: 0.23.0 dev: false + /react-hook-form@7.53.0(react@18.2.0): + resolution: {integrity: sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + dependencies: + react: 18.2.0 + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}