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
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,28 @@ 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;

// 옵션 추가/제거 핸들러
const handleAddOption = () => {
const current = getValues('voteOptions') ?? [];
if (current.length >= MAX_OPTIONS) return;
append({ content: '' });
};

const handleRemoveOption = (index: number) => {
const current = getValues('voteOptions') ?? [];
if (current.length <= MIN_OPTIONS) return;
remove(index);
};

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

Expand All @@ -117,11 +143,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 +266,55 @@ 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={handleAddOption}
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={() => handleRemoveOption(index)}
/>
</motion.div>
))}
</AnimatePresence>
</div>

{typeof errors.voteOptions?.message === 'string' && (
<p className="text-xs text-red-500">
{errors.voteOptions?.message}
Expand All @@ -278,22 +324,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 +345,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