Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 88 additions & 64 deletions src/components/common/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useEffect } from "react";
import { useRef, useEffect, forwardRef } from "react";

type TextAreaType = "title" | "content";

Expand Down Expand Up @@ -27,75 +27,99 @@ type TextAreaProps = {
className?: string;
textareaClassName?: string;
disabled?: boolean;

isError?: boolean;
};

export function TextArea({
type,
value,
onChange,
placeholder = "",
maxLength,
minLength,
emptyHint = "min",
className = "",
textareaClassName = "",
disabled = false,
}: TextAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
(
{
type,
value,
onChange,
placeholder = "",
maxLength,
minLength,
emptyHint = "min",
className = "",
textareaClassName = "",
disabled = false,
isError = false,
},
forwardedRef,
) => {
const innerRef = useRef<HTMLTextAreaElement | null>(null);

// forwardedRef + innerRef 같이 연결
const setRefs = (el: HTMLTextAreaElement | null) => {
innerRef.current = el;
if (!forwardedRef) return;
if (typeof forwardedRef === "function") forwardedRef(el);
else forwardedRef.current = el;
};

const length = value.length;
const hasTyped = length > 0; // 한 글자라도 입력했는가
const isOverMax = typeof maxLength === "number" && length > maxLength;
const length = value.length;
const hasTyped = length > 0; // 한 글자라도 입력했는가
const isOverMax = typeof maxLength === "number" && length > maxLength;

const heightStyle =
type && HEIGHT_BY_TYPE[type]
? {
minHeight: HEIGHT_BY_TYPE[type].min,
maxHeight: HEIGHT_BY_TYPE[type].max,
}
: {
minHeight: "5.5rem",
maxHeight: "11.4375rem",
};
const heightStyle =
type && HEIGHT_BY_TYPE[type]
? {
minHeight: HEIGHT_BY_TYPE[type].min,
maxHeight: HEIGHT_BY_TYPE[type].max,
}
: {
minHeight: "5.5rem",
maxHeight: "11.4375rem",
};

// 내용에 따라 높이 자동 조절
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
// 내용에 따라 높이 자동 조절
useEffect(() => {
const el = innerRef.current;
if (!el) return;

el.style.height = "auto"; // 초기화
el.style.height = `${el.scrollHeight}px`; // 내용만큼 증가
}, [value]);
el.style.height = "auto"; // 초기화
el.style.height = `${el.scrollHeight}px`; // 내용만큼 증가
}, [value]);

return (
<div
className={`border-neutral-750 flex flex-col gap-[0.625rem] rounded-2xl border bg-neutral-900 p-[1.25rem] ${className}`}
>
<textarea
ref={textareaRef}
value={value}
maxLength={maxLength}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={`textarea-scrollbar placeholder:text-neutral-750 w-full resize-none overflow-y-auto bg-transparent text-[1rem] text-neutral-200 outline-none ${textareaClassName} `}
style={heightStyle}
/>
return (
<div
className={[
"flex flex-col gap-[0.625rem] rounded-2xl border bg-neutral-900 p-[1.25rem]",
isError ? "border-red-500" : "border-neutral-750",
className,
]
.filter(Boolean)
.join(" ")}
>
<textarea
ref={setRefs}
value={value}
maxLength={maxLength}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={`textarea-scrollbar placeholder:text-neutral-750 w-full resize-none overflow-y-auto bg-transparent text-[1rem] text-neutral-200 outline-none ${textareaClassName} `}
style={heightStyle}
/>

{/* 우측 하단: 입력 전에는 안내 / 입력 후에는 카운터 */}
<div className="flex justify-end text-[0.75rem]">
{!hasTyped && emptyHint === "max" && typeof maxLength === "number" ? (
<span className="text-neutral-500">최대 {maxLength}자 이내</span>
) : !hasTyped &&
emptyHint === "min" &&
typeof minLength === "number" ? (
<span className="text-neutral-500">최소 {minLength}자 이상</span>
) : typeof maxLength === "number" ? (
<span className={isOverMax ? "text-orange-500" : "text-neutral-400"}>
{length}/{maxLength}
</span>
) : null}
{/* 우측 하단: 입력 전에는 안내 / 입력 후에는 카운터 */}
<div className="flex justify-end text-[0.75rem]">
{!hasTyped && emptyHint === "max" && typeof maxLength === "number" ? (
<span className="text-neutral-500">최대 {maxLength}자 이내</span>
) : !hasTyped &&
emptyHint === "min" &&
typeof minLength === "number" ? (
<span className="text-neutral-500">최소 {minLength}자 이상</span>
) : typeof maxLength === "number" ? (
<span
className={isOverMax ? "text-orange-500" : "text-neutral-400"}
>
{length}/{maxLength}
</span>
) : null}
</div>
</div>
</div>
);
}
);
},
);
80 changes: 73 additions & 7 deletions src/pages/photoFeed/NewPostPage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useRef } from "react";
import { useNavigate } from "react-router";
import { useNewPostState } from "@/store/useNewPostState.store";
import { TextArea } from "@/components/common/TextArea";
import { isValidText } from "@/utils/isValidText";
import { CTA_Button, Header } from "@/components/common";
import { scrollToCenter } from "@/utils/scrollToCenter";

const LIMITS = {
titleMin: 2,
Expand All @@ -15,6 +16,11 @@ const LIMITS = {

export default function NewPostPage() {
const navigate = useNavigate();
const [titleError, setTitleError] = useState(false);
const [contentError, setContentError] = useState(false);
const titleRef = useRef<HTMLTextAreaElement | null>(null);
const contentRef = useRef<HTMLTextAreaElement | null>(null);

const files = useNewPostState((s) => s.files);
const setPostInfo = useNewPostState((s) => s.setPostInfo);
const reset = useNewPostState((s) => s.reset);
Expand Down Expand Up @@ -47,10 +53,36 @@ export default function NewPostPage() {
[contentText],
);

const canGoNext = isTitleValid && isContentValid;

const handleNext = () => {
if (!canGoNext) return;
// 1) 제목 먼저 체크
if (!isTitleValid) {
setTitleError(true);
setContentError(false); // 첫 에러만 강조

const el = titleRef.current;
if (el) {
scrollToCenter(el);
el.focus();
}
return;
}

// 2) 그 다음 내용 체크
if (!isContentValid) {
setContentError(true);
setTitleError(false);

const el = contentRef.current;
if (el) {
scrollToCenter(el);
el.focus();
}
return;
}

// 3) 둘 다 valid이면 다음
setTitleError(false);
setContentError(false);
setPostInfo(titleText, contentText);
navigate("/photoFeed/lab/find");
};
Comment on lines 56 to 88
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

handleNext 함수의 유효성 검사 로직에 오류가 있습니다. isContentValid가 참이지만 isTitleValid가 거짓인 경우, 제목 필드에 대한 유효성 검사를 건너뛰고 폼이 제출됩니다. 이로 인해 유효하지 않은 제목으로 게시물이 생성될 수 있습니다. 두 필드를 각각 독립적으로 확인한 후 다음 단계로 진행하도록 로직을 수정해야 합니다.

  const handleNext = () => {
    const isTitleInvalid = !isTitleValid;
    const isContentInvalid = !isContentValid;

    setTitleError(isTitleInvalid);
    setContentError(isContentInvalid);

    if (isTitleInvalid) {
      const el = titleRef.current;
      if (el) {
        scrollToCenter(el);
        el.focus();
      }
      return;
    }

    if (isContentInvalid) {
      const el = contentRef.current;
      if (el) {
        scrollToCenter(el);
        el.focus();
      }
      return;
    }

    setPostInfo(titleText, contentText);
    navigate("/photoFeed/lab/find");
  };

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

첫번째 에러만 강조하는 로직으로 변경: 제목 체크 -> 내용 체크

Expand Down Expand Up @@ -90,25 +122,59 @@ export default function NewPostPage() {
<section className="flex flex-col gap-[0.5rem]">
<p className="text-[0.875rem] text-white">제목</p>
<TextArea
ref={titleRef}
type="title"
value={titleText}
onChange={setTitleText}
onChange={(v) => {
setTitleText(v);
if (
titleError &&
isValidText(v, LIMITS.titleMin, LIMITS.titleMax)
) {
setTitleError(false);
}
}}
Comment on lines +128 to +136
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

에러 상태가 한 글자 입력이 늦게 해제되는 문제가 있습니다. isTitleValid는 비동기적으로 업데이트되는 titleText 상태에 의존하는 memoized 값이기 때문입니다. 더 나은 사용자 경험을 위해, onChange 핸들러 내에서 새로운 값으로 직접 유효성을 검사하여 즉시 에러 상태를 해제하는 것이 좋습니다.

            onChange={(v) => {
              setTitleText(v);
              if (titleError && isValidText(v, LIMITS.titleMin, LIMITS.titleMax)) {
                setTitleError(false);
              }
            }}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영

placeholder="제목을 입력해주세요."
minLength={LIMITS.titleMin}
maxLength={LIMITS.titleMax}
isError={titleError}
/>
{titleError && (
<p
className={`px-[0.625rem] text-[0.875rem] font-normal text-orange-500`}
>
최소 2글자 이상 입력해주세요.
</p>
)}
</section>

<section className="flex flex-col gap-[0.5rem]">
<p className="text-[0.875rem] text-white">설명</p>
<TextArea
ref={contentRef}
type="content"
value={contentText}
onChange={setContentText}
onChange={(v) => {
setContentText(v);
if (
contentError &&
isValidText(v, LIMITS.contentMin, LIMITS.contentMax)
) {
setContentError(false);
}
}}
Comment on lines +157 to +165
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

제목 필드와 마찬가지로, 내용 필드의 에러 상태도 한 글자 늦게 해제됩니다. 사용자 경험을 개선하기 위해 onChange 핸들러 내에서 입력된 값으로 직접 유효성을 검사하여, 입력이 유효해지는 즉시 에러를 해제하도록 수정하는 것을 권장합니다.

            onChange={(v) => {
              setContentText(v);
              if (contentError && isValidText(v, LIMITS.contentMin, LIMITS.contentMax)) {
                setContentError(false);
              }
            }}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영

placeholder="나만의 필름 사진 이야기를 공유해주세요."
minLength={LIMITS.contentMin}
maxLength={LIMITS.contentMax}
isError={contentError}
/>
{contentError && (
<p
className={`px-[0.625rem] text-[0.875rem] font-normal text-orange-500`}
>
최소 20글자 이상 입력해주세요.
</p>
)}
</section>

<hr className="border-neutral-800" />
Expand Down Expand Up @@ -157,7 +223,7 @@ export default function NewPostPage() {
<CTA_Button
text="다음"
size="xlarge"
color={canGoNext ? "orange" : "black"}
color={isTitleValid && isContentValid ? "orange" : "black"}
onClick={handleNext}
/>
</div>
Expand Down
Loading