Skip to content

Commit

Permalink
Merge pull request #4 from academic-relations/3-refactor-phoneinput-c…
Browse files Browse the repository at this point in the history
…ursor

Refactor PhoneInput fix Cursor Error
  • Loading branch information
ChaeyeonAhn authored Oct 7, 2024
2 parents d2ab657 + bdfa0e9 commit 1d3d643
Show file tree
Hide file tree
Showing 3 changed files with 179 additions and 35 deletions.
2 changes: 2 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
185 changes: 151 additions & 34 deletions packages/web/src/common/components/Forms/PhoneInput.tsx
Original file line number Diff line number Diff line change
@@ -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<TextInputProps, "onChange"> {
value: string;
onChange: (value: string) => void;
/*
--- 사용 방법 (예시) ---
<NewPhoneInput
label="전화번호"
placeholder="010-XXXX-XXXX"
value={phone}
handleChange={setPhone}
setErrorStatus={setErrorPhone}
/>
*/

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<HTMLInputElement | HTMLTextAreaElement> {
label?: string;
placeholder: string;
disabled?: boolean;
value?: string;
handleChange?: (value: string) => void;
setErrorStatus?: (hasError: boolean) => void;
onChange?: ChangeEventHandler<HTMLInputElement>;
}

const PhoneInput: React.FC<PhoneInputProps> = ({
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",
})<PhoneInputProps & { hasError: boolean }>`
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<PhoneInputProps & { optional?: boolean }> = ({
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<HTMLInputElement | null>(null);
const cursorRef = useRef<number>(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("");
Expand All @@ -39,42 +137,61 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
setTouched(true);
};

const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
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) {
formattedInput = `${digits.slice(0, 3)}-${digits.slice(3)}`;
} else if (digits.length <= 11) {
formattedInput = `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
}

return formattedInput;
};

const handlePhoneValueChange = (e: ChangeEvent<HTMLInputElement>) => {
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 (
<TextInput
label={label}
value={formatValue(value)}
onChange={handleChange}
errorMessage={error}
onBlur={handleBlur}
{...props}
/>
<InputWrapper>
{label && <Label>{label}</Label>}
<InputWrapper>
<Input
placeholder={placeholder}
hasError={!!error}
disabled={disabled}
value={formatValue(value)}
onChange={onChange ?? handlePhoneValueChange}
onBlur={handleBlur}
ref={inputRef}
{...props}
/>
{error && <ErrorMessage>{error}</ErrorMessage>}
</InputWrapper>
</InputWrapper>
);
};
// TODO: DB 값 default로 넣어두기

export default PhoneInput;
27 changes: 26 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 1d3d643

Please sign in to comment.