[Next.js] 리뷰 작성 UX 개선하기
자동으로 스크롤되면 얼마나 좋을까?
관련 PR
리뷰 후기 작성 api 연결
리뷰 후기 작성 auto focusing 추가
🔥들어가기
리뷰 작성 폼은 이미 다 만들어져 있고, 디자이너와 소통하며 UX를 점차 개선해나갔다.
UI를 해지지 않는 선에서 UX를 최대한 개선하려고 했고 그 내용은 다음과 같다.
- 이미지 여러 개 업로드
- 자동 스크롤, 자동 Dropdown 열림
- 유효성 검사 후 해당 항목으로 자동 이동
순서대로 어떻게 개선했는지 코드와 함께 설명해보겠다.
🔥1. 이미지 여러 개 업로드
🤔문제점
우리 서비스는 이미지를 총 4장까지만 업로드할 수 있고, 한 장씩 업로드를 하다가 4장이 되면 업로드 버튼이 막힌다.
충분히 직관적이지만 이미지를 여러 개 올릴 수 있는 다른 서비스가 많기 때문에 편의를 위해 여러 장을 업로드하는 것으로 변경하였다.
🌈해결 방법
ImageUpLoadArea.tsx
multiple
속성을 추가하여 여러 개를 업로드 할 수 있게 했다.
단, 4개로 제한을 두었기 때문에 앞에서부터 4개를 업로드하고 토스트를 띄웠다.
<input
type="file"
multiple // 속성 추가
accept="image/*"
style=
ref={fileInputRef}
onChange={onChange}
/>
💻결과 화면
🔥2. 자동 스크롤 & Dropdown 자동 열림
새로운 섹션이 뜰 때만 자동 스크롤
리뷰 작성은 총 7단계로 이루어져 있다. 위에서부터 각 항목이 유효해지면 다음 섹션이 뜨면서 자동 이동하게 했다. 이미 다음 항목이 보이는 상태면 자동 이동하지 않는다.
그럼 7개 항목 하나씩 어떻게 개선했는지 살펴보자.
1. 콘서트 선택
concertId 값이 바뀌면 좌석 선택으로 바로 이동시켜 주세요.
1. 콘서트가 선택되면 concertId가 NONE_SELECT
에서 선택된 값으로 바뀌므로 다음 섹션으로 스크롤이 이동한다.
const handleSelect = (concert: StadiumConcertInfo) => {
setInputValue(concert.concertName);
dispatch({
type: REVIEW.ACTIONS.CONCERT_SELECT,
payload: { concertId: concert.concertId },
});
};
2. 검색을 위해 input에 value가 바뀌면 dispatch를 실행시키지 않는다. 단, 선택된 concertId가 있는 상태에서 value를 변경하면 dispatch를 실행시켜 concertId를 초기화 한다. 이때는 다음 섹션이 이미 렌더링되어 있는 상태이기 때문에 다음 섹션으로 자동 이동하지 않는다.
const handleInputChange = (value: string) => {
setInputValue(value);
if (data !== NONE_SELECT) {
dispatch({
type: REVIEW.ACTIONS.CONCERT_SELECT,
payload: { concertId: NONE_SELECT },
});
}
};
2. 좌석 선택
좌석 선택 dropdown이 자동으로 열리게 해주세요. seatingId가 선택되면 특징 선택으로 바로 이동시켜 주세요.
dropdown이 하나씩 마운트 되기 때문에 마운트 될 때 자동으로 열리게 한다. 상위 값이 바뀌어 값이 초기화가 되면 autoOpen을 true로 만들어 자동 열림이 뜨게 했다.
ReviewDropdown.tsx
value가 바뀌면 useEffect가 트리거 되고, 그중에서도 값이 없다면 dropdown을 연다.
useEffect(() => {
if (autoOpen) {
handleOpenDropdown();
}
}, [value]);
SeatInfoSelect.tsx
<ReviewDropdown
value={seatInfo.floor}
onChange={(floorName) =>
handleSeatInfoSelect({
floor: floorName,
section: "",
seatingId: NONE_SELECT,
})
}
options={fetchedSeats.map((floor) => floor.name)}
placeholder="층을 선택해주세요"
autoOpen={seatInfo.floor === ""} // autoOpen 추가
/>
3. 특징 선택
features는 여러 개 선택할 수 있기 때문에 하나만 선택해도 다음 섹션이 보이지만 스크롤 이동은 없어요.
4. 이미지 업로드
images는 사진 여러 개 한 번에 넣을 수 있기 때문에 업로드 하자마자 다음으로 이동시켜 주세요.
5. 거리 정보 선택
stageDistance 세 개 정보는 라디오 버튼이고 취소가 안 되기 때문에 3개를 다 선택하면 다음으로 이동시켜 주세요.
6. 방해요소 선택
obstructions는 features와 동일하게 여러 개 선택할 수 있어요. 하나만 선택해도 다음 섹션이 보이지만 스크롤 이동은 없어요.
7. 후기 글 작성
contents는 사용자가 textarea에 focusing하면 위로 이동시켜 주세요.
ReviewContents.tsx
const ReviewContents = ({ data, dispatch }: ReviewContentsProps) => {
const reviewContentsRef = useRef<HTMLDivElement>(null);
const handleFocus = () => {
reviewContentsRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
};
return (
<div ref={reviewContentsRef} style=>
<Textarea
value={data}
onChange={(e) => handleReviewContents(e.target.value)}
maxLength={300}
placeholder={REVIEW.MESSAGE.REVIEW_INPUT.PLACEHOLDER}
rows={5}
onFocus={handleFocus} // focus되면 이 항목을 제일 위로 올림
/>
</div>
);
};
다음 항목으로 자동 스크롤
그렇다면 다음 항목으로 자동 스크롤되는 코드는 어떻게 구현되어 있을까?
ReviewForm.tsx
우선 useAutoScroll
훅을 호출하는 ReviewForm
컴포넌트부터 살펴보자.
stepRef.current
는 현재 화면에 보이는 마지막 항목이다. sectionRefs
를 선언하고, assignRef
함수를 사용해서 각 항목에 ref를 달아준다.
const ReviewForm = ({ reviewData, dispatch, onSubmit }: ReviewFormProps) => {
const stepRef = useRef<number>(0);
stepRef.current = Math.max(stepRef.current, reviewData.currentStep);
const isRender = (step: number) => stepRef.current >= step;
// sectionRefs 선언
const sectionRefs = useRef({
concertId: null,
seatingId: null,
features: null,
images: null,
stageDistance: null,
thrustStageDistance: null,
screenDistance: null,
obstructions: null,
contents: null,
submit: null,
} as Record<string, HTMLDivElement | null>);
type SectionKeys = keyof typeof sectionRefs.current;
// 각 섹션에 ref 달아주기
const assignRef = (key: string) => (el: HTMLDivElement | null) => {
sectionRefs.current[key] = el;
};
// useAutoScroll 호출
useAutoScroll<SectionKeys>(
stepRef.current, // 현재 화면에 보이는 마지막 항목
sectionRefs.current
);
return (
<form className={styles.reviewFormLayout}>
{isRender(REVIEW.STEPS.CONCERT_SELECT) && (
<ReviewSection ref={assignRef("concertId")}>
{/* 콘서트 항목 내용 */}
</ReviewSection>
)}
{/* 나머지 항목들 */}
</form>
);
};
useAutoScroll.ts
1. 7개 항목 중에 총 4번 자동 스크롤이 일어난다. stepToKey에 있는 섹션들이 자동 스크롤로 보여지는 섹션들이다.
- concertId -> seatingId
- seatingId -> features
- images -> stageDistance
- stageDistance -> obstructions
2. deps에 lastStep
, 즉 stepRef.current
가 있다. 따라서 새로운 섹션이 뜰 때만 자동 스크롤이 일어난다.
export function useAutoScroll<Keys>(
lastStep: number,
sectionRefs: Record<string, HTMLDivElement | null>
) {
// 1. 자동 스텝 변경 시 해당 섹션으로 스크롤
useEffect(() => {
const stepToKey: Record<number, Keys> = {
[REVIEW.STEPS.SEAT_INFO_SELECT]: "seatingId" as Keys,
[REVIEW.STEPS.FEATURES_INFO_SELECT]: "features" as Keys,
[REVIEW.STEPS.DISTANCE_INFO_SELECT]: "stageDistance" as Keys,
[REVIEW.STEPS.OBSTRUCTIONS_SELECT]: "obstructions" as Keys,
} as const;
const key = stepToKey[lastStep];
if (key) {
sectionRefs[key]?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}, [lastStep]); // 2. 새로운 섹션이 뜰 때만 자동 스크롤
}
최종 비교 화면
상태 | 영상 비교 |
---|---|
이전 | |
이후 |
성과
- 스크롤 횟수: 7번 -> 0번
- 클릭 횟수: 18번 -> 15번
🔥3. 후기 입력 유효성 검사
7개 항목 모두 필수 입력이기 때문에 빈 값으로 서버에 보내지지 않기 위해 유효성 검사를 해야했다.
페이지가 길기 때문에 “작성 완료”를 눌렀을 때 유효하지 않은 곳으로 자동 스크롤되면 어떨까? 라는 생각이 들어 바로 useAutoScroll
훅을 개선해보았다.
ReviewForm.tsx
1. 유효하지 않은 항목들이 담긴 배열을 만들고 작성 완료 버튼을 눌렀을 때 useAutoScroll
의 scrollToInvalid
함수를 실행한다.
2. 갑자기 scroll이 이동하면 사용자가 예상하지 못 할 것 같아 toast를 추가하였다.
3. “작성 완료” 클릭 여부 상태(triedSubmit
)를 추가해 버튼이 눌렸고, 해당 항목이 유효하지 않다면 ReviewSection
에 빨간 border를 추가했다.(isInvalid
속성)
const ReviewForm = ({ reviewData, dispatch, onSubmit }: ReviewFormProps) => {
const [triedSubmit, setTriedSubmit] = useState(false);
const { showPopup } = usePopup();
const { activateToast } = useToast();
// --- sectionRefs 선언, assignRef 함수 코드 생략 ---
// 위에서부터 유효하지 않은 항목이 담긴 배열
const invalidFields = getInvalidFields(reviewData) as SectionKeys[];
const { scrollToInvalid } = useAutoScroll<SectionKeys>(
stepRef.current,
sectionRefs.current
);
const handleSubmitButton = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setTriedSubmit(true);
// 유효하지 않은 경우: toast
if (invalidFields.length > 0) {
scrollToInvalid(invalidFields);
activateToast("입력하지 않은 정보가 있어요!", "Warning");
return;
}
// 유효한 경우: popup
showPopup({
title: "후기를 등록하시겠습니까?",
subtitle:
"등록된 후기는 수정/삭제가 불가합니다. 민감한 정보가 들어간 후기는 관리자에 의해 삭제 처리될 수 있습니다.",
onConfirm: onSubmit,
});
};
return (
<form className={styles.reviewFormLayout}>
{isRender(REVIEW.STEPS.CONCERT_SELECT) && (
<ReviewSection
ref={assignRef("concertId")}
isInvalid={triedSubmit && invalidFields.includes("concertId")} // 빨간 border를 위해 추가
>
<ReviewSection.Title
title={REVIEW.MESSAGE.CONCERT_SELECT.TITLE}
subtitle={REVIEW.MESSAGE.CONCERT_SELECT.SUBTITLE}
/>
<ConcertSelect
stadiumId={reviewData.stadiumId}
data={reviewData.concertId}
dispatch={dispatch}
/>
</ReviewSection>
)}
{/* 나머지 항목들 */}
<div
ref={assignRef("submit")}
className={classNames(styles.submit, {
[styles.visible]: isRender(REVIEW.STEPS.SUBMIT),
})}
>
<Button
variant={
getInvalidFields(reviewData).length > 0 ? "inactive" : "primary" // 모두 유효하면 버튼 색 변경
}
onClick={handleSubmitButton}
>
<span className={styles.submitButtonText}>작성완료</span>
</Button>
<pre className={styles.reviewRule}>
{REVIEW.MESSAGE.REVIEW_RULE.TEXT}
</pre>
</div>
</form>
);
};
getInvalidFields.ts
ReviewData를 확인하여 가장 위에 항목부터 유효하지 않다면 배열에 넣는다.
export const getInvalidFields = (data: ReviewData): (keyof ReviewData)[] => {
const invalids: (keyof ReviewData)[] = [];
if (data.concertId === NONE_SELECT) invalids.push("concertId");
if (data.seatingId === NONE_SELECT) invalids.push("seatingId");
if (data.features.length === 0) invalids.push("features");
if (data.images.length === 0) invalids.push("images");
if (data.stageDistance === "") invalids.push("stageDistance");
if (data.thrustStageDistance === "") invalids.push("thrustStageDistance");
if (data.screenDistance === "") invalids.push("screenDistance");
if (data.obstructions.length === 0) invalids.push("obstructions");
if (data.contents.trim() === "") invalids.push("contents");
return invalids;
};
useAutoScroll.ts
유효하지 않은 가장 상위 섹션으로 이동시킨다. 그 섹션이 “작성 완료” 버튼과 가장 멀기 때문에 자동 스크롤로 이동하고 이후에 사용자가 직접 아래로 스크롤하며 항목을 채워나가 “작성 완료” 버튼에 가까워지면 UX가 향상될 거라 생각했다.
export function useAutoScroll<Keys>(
lastStep: number,
sectionRefs: Record<string, HTMLDivElement | null>
) {
// --- 자동 스텝 변경 시 해당 섹션으로 스크롤 코드 생략 ---
// 유효성 검사 실패 필드 배열을 받아 첫 번째에 해당하는 섹션으로 스크롤
const scrollToInvalid = (invalidFields: Keys[]) => {
if (invalidFields.length === 0) return;
const firstKey = invalidFields[0];
sectionRefs[firstKey]?.scrollIntoView({
behavior: "smooth",
block: "start",
});
};
return { scrollToInvalid };
}
빨간 border, 유효성 배열 추가 전후 비교 화면
상태 | 영상 비교 |
---|---|
이전 | |
이후 |
성과
(77777ㅑ 디자이너와 PM한테 칭찬 받았당😁)
댓글남기기