Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { useMemo, useState } from 'react';
import { useForm, useFieldArray, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion, AnimatePresence } from 'motion/react';
import Input from '@/components/inputs/Input';
import TextArea from '@/components/inputs/TextArea';
import { Button } from '@/components/buttons/Button';
Expand All @@ -17,6 +18,7 @@ import { VoteboardOptionField } from './VoteoptionField';
import { CATEGORIES, Category } from '../../constants/categories';
import { ImageUploader } from '@/components/ImageUploader';
import { Select } from '@/components/select/Select';
import { RoundCheckbox } from '@/components/inputs/RoundCheckbox';

export interface VoteboardFormProps {
/** 수정할 투표 게시글 ID (없으면 생성 모드) */
Expand Down Expand Up @@ -75,6 +77,8 @@ export function VoteboardForm({
control,
setValue,
handleSubmit,
watch,
getValues,
formState: { errors, touchedFields, isValid },
} = useForm<VoteboardFormData>({
resolver: zodResolver(voteboardSchema),
Expand Down Expand Up @@ -102,6 +106,15 @@ export function VoteboardForm({
name: 'voteOptions',
});

const watchedOptions = watch('voteOptions');
const optionCount = watchedOptions?.length ?? fields.length;

const MAX_OPTIONS = 5;
const MIN_OPTIONS = 2;

// 옵션 추가 가능 여부 (생성 모드 + 최대 5개)
const canAddMore = !isEdit && optionCount < MAX_OPTIONS;

// 생성/수정 mutation 훅
const { submitPost, isPending } = useVoteboardMutation(voteboardId);

Expand All @@ -117,11 +130,11 @@ export function VoteboardForm({
};

return (
<div className="relative flex flex-col h-full w-full ">
<div className="relative flex flex-col h-full w-full overflow-y-auto">
<form
id="vote-form"
aria-label={isEdit ? '투표 게시글 수정' : '투표 게시글 작성'}
className="flex flex-col gap-4 w-full flex-1 overflow-auto p-1 transition-transform duration-300 ease-in-out pb-16"
className="flex flex-col gap-4 w-full p-1 transition-transform duration-300 ease-in-out"
onSubmit={handleSubmit(onSubmit)}
>
<div>
Expand Down Expand Up @@ -240,35 +253,63 @@ export function VoteboardForm({
*
</span>
</label>
{!isEdit && (
<button
type="button"
className="text-xs text-soso-500"
onClick={() => {
if (fields.length >= 5) return;
append({ content: '' });
}}
>
<Plus className="inline-block w-3 h-3 mr-1" />
</button>
)}

<AnimatePresence initial={false}>
{canAddMore && (
<motion.button
key="add-option"
type="button"
className="text-xs text-soso-500"
aria-label="투표 옵션 추가"
onClick={() => {
const current = getValues('voteOptions') ?? [];
if (current.length >= MAX_OPTIONS) return;
append({ content: '' });
}}
Copy link
Member

Choose a reason for hiding this comment

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

복잡한 클릭 이벤트 핸들러는 분리하는게 좋아보여요

Copy link
Member

Choose a reason for hiding this comment

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

또, useCallback의 필요성도 확인해주세요

Copy link
Member Author

@youdaeng2 youdaeng2 Dec 10, 2025

Choose a reason for hiding this comment

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

리뷰 감사합니다.
주신 피드백 반영하여 인라인으로 있던 핸들러는 분리하였고,
useCallback에 대해서도 검토해보았습니다!
useCallback은

  1. 메모된 하위 컴포넌트에 props로 넘겨 렌더를 줄이고 싶을 때
  2. 의존성 배열에 들어가는 함수를 안정화해야 할 때
    주로 사용하는데 현재는 해당하는 부분이 없어 제거하였습니다.

initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
whileTap={{ scale: 1.3 }}
whileHover={{ scale: 1.05 }}
>
<Plus className="inline-block w-3 h-3 mr-1" />
</motion.button>
)}
</AnimatePresence>
</div>

{/* 옵션 필드 */}
<div className="flex flex-col gap-2">
{fields.map((field, index) => (
<VoteboardOptionField
key={field.id}
index={index}
register={register}
errorMessage={
errors.voteOptions?.[index]?.content?.message
}
editable={!isEdit}
canRemove={!isEdit && fields.length > 2}
onRemove={() => remove(index)}
/>
))}
<AnimatePresence initial={false}>
{fields.map((field, index) => (
<motion.div
key={field.id}
layout
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.18 }}
>
<VoteboardOptionField
index={index}
register={register}
errorMessage={
errors.voteOptions?.[index]?.content?.message
}
editable={!isEdit}
canRemove={!isEdit && optionCount > MIN_OPTIONS}
onRemove={() => {
const current = getValues('voteOptions') ?? [];
if (current.length <= MIN_OPTIONS) return;
remove(index);
Copy link
Member

Choose a reason for hiding this comment

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

여기도 동일하게 분리하는게 좋아보여요

}}
/>
</motion.div>
))}
</AnimatePresence>
</div>

{typeof errors.voteOptions?.message === 'string' && (
<p className="text-xs text-red-500">
{errors.voteOptions?.message}
Expand All @@ -278,22 +319,14 @@ export function VoteboardForm({

{/* 설정 (복수 선택 / 재투표) */}
<div className="flex flex-col gap-2 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="w-4 h-4"
{...register('allowMultipleChoice')}
/>
<span>복수 선택 허용</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
className="w-4 h-4"
{...register('allowRevote')}
/>
<span>재투표 허용</span>
</label>
<RoundCheckbox
label="복수 선택 허용"
{...register('allowMultipleChoice')}
/>
<RoundCheckbox
label="재투표 허용"
{...register('allowRevote')}
/>
</div>

{/* 이미지 업로드 */}
Expand All @@ -307,17 +340,19 @@ export function VoteboardForm({
onDeleteExisting={handleDeleteExisting}
/>
</div>
</form>

<Button
type="submit"
form="vote-form"
disabled={!isValid || isPending}
isLoading={isPending}
className="absolute bottom-0 w-full"
>
저장하기
</Button>
{/* 버튼 */}
<div className="sticky bottom-0 left-0 right-0 bg-white/90 dark:bg-neutral-900/90 pt-2">
<Button
type="submit"
disabled={!isValid || isPending}
isLoading={isPending}
className="w-full"
>
저장하기
</Button>
</div>
</form>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { X } from 'lucide-react';
import Input from '@/components/inputs/Input';
import type { VoteboardFormData } from '../schema/voteboardSchema';
import type { UseFormRegister } from 'react-hook-form';
import { motion, AnimatePresence } from 'motion/react';

interface VoteboardOptionFieldProps {
/** 옵션 인덱스 (0부터 시작) */
Expand Down Expand Up @@ -37,25 +38,43 @@ export function VoteboardOptionField({
onRemove,
}: VoteboardOptionFieldProps) {
return (
<div className="flex items-center gap-2">
<Input
id={`option-${index}`}
placeholder="투표 옵션을 입력하세요"
isError={!!errorMessage}
errorMessage={errorMessage}
disabled={!editable}
{...register(`voteOptions.${index}.content` as const)}
/>
{canRemove && (
<button
type="button"
className="text-xs text-neutral-400"
onClick={onRemove}
aria-label={`옵션 ${index + 1} 삭제`}
>
<X className="inline-block w-4 h-4" />
</button>
)}
</div>
<motion.div
className="flex items-start gap-2"
layout
transition={{ duration: 0.2 }}
>
{/* 인풋 + 에러 메시지 영역 */}
<motion.div className="flex-1" layout>
<Input
id={`option-${index}`}
placeholder="투표 옵션을 입력하세요"
isError={!!errorMessage}
errorMessage={errorMessage}
disabled={!editable}
{...register(`voteOptions.${index}.content` as const)}
/>
</motion.div>

{/* X 버튼: 높이 46px 박스 안에서 세로 가운데 정렬 */}
<div className="h-[46px] flex items-center">
<AnimatePresence initial={false}>
{canRemove && (
<motion.button
key="remove"
type="button"
onClick={onRemove}
aria-label={`옵션 ${index + 1} 삭제`}
className="text-xs text-neutral-400"
initial={{ opacity: 0, x: 8 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 8 }}
transition={{ duration: 0.15 }}
>
Comment on lines +60 to +72
Copy link
Member

Choose a reason for hiding this comment

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

접근성과 상세한 애니메이션 설정이 대단하군요

<X className="inline-block w-4 h-4" />
</motion.button>
)}
</AnimatePresence>
</div>
</motion.div>
);
}
64 changes: 64 additions & 0 deletions apps/web/src/components/inputs/RoundCheckbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { InputHTMLAttributes } from 'react';
import { Check } from 'lucide-react';
import { twMerge } from 'tailwind-merge';

export interface RoundCheckboxProps
extends InputHTMLAttributes<HTMLInputElement> {
/** 체크박스 오른쪽에 표시할 라벨 텍스트 */
label?: string;
}

/**
* 동그란 디자인의 커스텀 체크박스
*
* - 비활성: 흰 배경, 뉴트럴 테두리, 뉴트럴 텍스트
* - 활성: SOSO 메인 배경, 흰 아이콘, 검정 텍스트
*/
export const RoundCheckbox = React.forwardRef<
HTMLInputElement,
RoundCheckboxProps
>(function RoundCheckbox(
{ label, id, name, className, ...inputProps },
ref,
) {
const inputId = id ?? (typeof name === 'string' ? name : undefined);

const boxClassName = twMerge(
// 기본 모양
'flex items-center justify-center w-4 h-4 rounded-full border transition-colors',
// 비활성 상태
'border-neutral-100 bg-white text-transparent',
// 활성(체크) 상태
'peer-checked:bg-soso-500 peer-checked:border-soso-500 peer-checked:text-white',
// 포커스
'peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-soso-500',
);

return (
<label
htmlFor={inputId}
className={twMerge(
'inline-flex h-5 items-center gap-2 cursor-pointer text-sm leading-none',
className,
)}
>
{/* 실제 체크박스 */}
<input
id={inputId}
name={name}
type="checkbox"
ref={ref}
className="peer sr-only"
{...inputProps}
/>

{/* 커스텀 체크박스 */}
<span className={boxClassName}>
<Check className="w-3 h-3" />
</span>

{/* 라벨 텍스트 */}
{label && <span className="text-neutral-900">{label}</span>}
</label>
);
});
Loading