Skip to content

Commit afd5007

Browse files
RookieANDclaude
andauthored
feat: Calendar 및 BottomSheet 컴포넌트 구현 (#85)
* feat: Calendar 및 BottomSheet 컴포넌트 구현 - Radix UI 기반 Dialog, BottomSheet 컴포넌트 구현 - react-day-picker 기반 Calendar 컴포넌트 구현 - ScheduledDateDialog로 약속 날짜 선택 기능 추가 - 6주 고정 그리드 레이아웃 (fixedWeeks) 적용 - 프로젝트 아이콘 시스템(ChevronLeftIcon, ChevronRightIcon) 활용 - Figma 디자인 시스템 완전 준수 (34px day cells, 정확한 정렬) - 과거 날짜 선택 비활성화 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * fix: Calendar 컴포넌트를 Figma 디자인 시안과 일치하도록 수정 - month 간격을 gap-2에서 gap-3으로 변경 (8px -> 12px) - weekday에 px-2 패딩 추가 (좌우 8px) - day_button에서 불필요한 tracking-[-0.27px] 제거 (body-18-sb에 이미 포함) - CalendarNav 커스텀 컴포넌트 구현으로 네비게이션 개선 - 왼쪽 화살표 버튼: p-1.5 (6px) - 오른쪽 화살표 버튼: p-2.5 rounded-lg (10px, 8px border-radius) Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * fix: Prettier 및 TypeScript 오류 수정 - Prettier 포맷팅 규칙에 맞게 클래스 순서 수정 - MonthCaption 컴포넌트 반환 타입을 null에서 빈 Fragment(<>)로 변경하여 TypeScript 오류 해결 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * style: Prettier 포맷팅 규칙 적용 - Dialog.tsx: JSX return 문을 여러 줄로 포맷팅 - RestaurantCard.tsx: 코드 스타일 규칙 적용 - color.css: CSS 포맷팅 규칙 적용 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * style: BottomSheet 디자인을 Figma 시안에 맞춰 수정 - Border radius를 20px에서 24px로 변경 - 내부 컨텐츠 padding 및 spacing 조정 (px-6 py-9, gap-5) - Drag handle은 유지하면서 레이아웃 구조 개선 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * feat: Calendar 컴포넌트를 Figma 시안에 맞춰 수정 - 폰트 크기 및 weight를 Figma 디자인에 맞춤 (heading-18-sb) - 색상 토큰 조정 (#848B9C, text-disabled 등) - 간격 조정 (day gap: 12px, week gap: 8px) - Custom DayButton 컴포넌트 추가하여 twMerge로 스타일 충돌 해결 - date-fns의 addMonths를 사용한 현재 월 계산 로직 추가 - 스타일을 CALENDAR_CLASS_NAMES에 집중하여 관리 개선 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * refactor: ScheduledDateDialog 버튼을 디자인 시스템 Button 컴포넌트로 교체 - 커스텀 button 태그를 Vapor UI Button 컴포넌트로 교체 - variant="primary", width="full" 설정으로 일관된 스타일 적용 - 레이아웃 gap 추가하여 간격 개선 (gap-13.5) - 불필요한 className 제거 (디자인 시스템이 관리) Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * refactor: ScheduledDateDialog를 ScheduledDatePicker로 이름 변경 - BottomSheet를 사용하므로 Dialog라는 이름이 부적절함 - 컴포넌트명, 폴더명, 파일명 모두 ScheduledDatePicker로 변경 - 관련 import 문 업데이트 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * style: color.ts Prettier 포맷팅 적용 - 인덴트 스타일을 Prettier 설정에 맞춰 조정 Co-Authored-By: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com> * fix: clear Button 을 input 에서 더 이상 노출시키지 않도록 수정 * fix: BottomSheet 내 Description, Title 에 Screen Only 속성 추가 * feat: 이전, 이후 달의 일자에 대해서는 text-disabled 처리하고 달 이전 기능 추가 * fix: 캘린더 내 달 설정 텍스트를 Bold 로 수정 * fix: scheduledDatePicker 를 pageComponents 폴더로 이전하여 관리 * fix: 코드 리뷰 반영하여 Animation Dampling 현상 수정 * fix: Portal 과 Content 영역의 Animation Duration 을 0.2 초로 맞추어 애니메이션이 동시에 끝나도록 수정 * refactor: ScheduledDatePicker 코드 구조 개선 - 상수 및 유틸리티 함수를 컴포넌트 외부로 분리 - 상태 변수명을 명확하게 변경 (selectedDate → pendingDate, displayMonth → calendarMonth) - 구조 분해 단순화 및 불필요한 중간 변수 제거 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: ScheduledDatePicker 유틸 함수를 컴포넌트 내부로 인라인 처리 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude (us.anthropic.claude-sonnet-4-5-20250929-v1:0) <noreply@anthropic.com>
1 parent 4ac87b5 commit afd5007

File tree

12 files changed

+826
-33
lines changed

12 files changed

+826
-33
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@hookform/resolvers": "^5.2.2",
1515
"@lottiefiles/react-lottie-player": "^3.6.0",
1616
"@next/third-parties": "^16.1.6",
17+
"@radix-ui/react-dialog": "^1.1.15",
1718
"@radix-ui/react-slot": "^1.2.4",
1819
"@tanstack/react-query": "^5.90.16",
1920
"class-variance-authority": "^0.7.1",
@@ -23,6 +24,7 @@
2324
"motion": "^12.26.1",
2425
"next": "16.1.1",
2526
"react": "19.2.3",
27+
"react-day-picker": "^9.13.2",
2628
"react-dom": "19.2.3",
2729
"react-hook-form": "^7.71.0",
2830
"sonner": "^2.0.7",

pnpm-lock.yaml

Lines changed: 439 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"use client";
2+
3+
import * as RadixDialog from "@radix-ui/react-dialog";
4+
import { AnimatePresence, motion } from "motion/react";
5+
import type { ReactNode } from "react";
6+
import { twJoin } from "tailwind-merge";
7+
8+
interface BottomSheetRootProps {
9+
open?: boolean;
10+
onOpenChange?: (open: boolean) => void;
11+
children: ReactNode;
12+
}
13+
14+
const BottomSheetRoot = ({
15+
open,
16+
onOpenChange,
17+
children,
18+
}: BottomSheetRootProps) => {
19+
return (
20+
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
21+
{children}
22+
</RadixDialog.Root>
23+
);
24+
};
25+
26+
interface BottomSheetTriggerProps {
27+
children: ReactNode;
28+
asChild?: boolean;
29+
}
30+
31+
const BottomSheetTrigger = ({
32+
children,
33+
asChild = true,
34+
}: BottomSheetTriggerProps) => {
35+
return (
36+
<RadixDialog.Trigger asChild={asChild}>{children}</RadixDialog.Trigger>
37+
);
38+
};
39+
40+
interface BottomSheetContentProps {
41+
children: ReactNode;
42+
open?: boolean;
43+
title?: string;
44+
description?: string;
45+
}
46+
47+
const BottomSheetContent = ({
48+
children,
49+
open,
50+
title = "Bottom Sheet",
51+
description = "Bottom Sheet Description",
52+
}: BottomSheetContentProps) => {
53+
return (
54+
<AnimatePresence>
55+
{open && (
56+
<RadixDialog.Portal forceMount>
57+
<RadixDialog.Overlay asChild forceMount>
58+
<motion.div
59+
initial={{ opacity: 0 }}
60+
animate={{ opacity: 1 }}
61+
exit={{ opacity: 0 }}
62+
transition={{
63+
type: "tween",
64+
duration: 0.2,
65+
ease: "easeInOut",
66+
}}
67+
className="ygi:fixed ygi:inset-0 ygi:z-50 ygi:bg-bg-dim"
68+
/>
69+
</RadixDialog.Overlay>
70+
<RadixDialog.Content asChild forceMount>
71+
<motion.div
72+
initial={{ y: "100%" }}
73+
animate={{ y: 0 }}
74+
exit={{ y: "100%" }}
75+
transition={{
76+
type: "tween",
77+
duration: 0.2,
78+
ease: "easeInOut",
79+
}}
80+
className={twJoin(
81+
"ygi:fixed ygi:right-0 ygi:bottom-0 ygi:left-0 ygi:z-50",
82+
"ygi:rounded-t-xl ygi:bg-surface-white ygi:shadow-lg",
83+
"ygi:mx-auto ygi:w-full ygi:max-w-root-layout ygi:overflow-y-auto",
84+
"focus:ygi:outline-none",
85+
)}
86+
>
87+
<RadixDialog.Title className="ygi:sr-only">
88+
{title}
89+
</RadixDialog.Title>
90+
<RadixDialog.Description className="ygi:sr-only">
91+
{description}
92+
</RadixDialog.Description>
93+
<div className="ygi:flex ygi:flex-col ygi:gap-5 ygi:px-6 ygi:pt-9 ygi:pb-4">
94+
{children}
95+
</div>
96+
</motion.div>
97+
</RadixDialog.Content>
98+
</RadixDialog.Portal>
99+
)}
100+
</AnimatePresence>
101+
);
102+
};
103+
104+
const BottomSheetClose = RadixDialog.Close;
105+
106+
export const BottomSheet = Object.assign(BottomSheetRoot, {
107+
Trigger: BottomSheetTrigger,
108+
Content: BottomSheetContent,
109+
Close: BottomSheetClose,
110+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { BottomSheet } from "./BottomSheet";
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { addMonths, isSameMonth, startOfMonth } from "date-fns";
5+
import { type CustomComponents, DayPicker } from "react-day-picker";
6+
import { twJoin, twMerge } from "tailwind-merge";
7+
8+
import { ChevronLeftIcon } from "#/icons/chevronLeftIcon";
9+
import { ChevronRightIcon } from "#/icons/chevronRightIcon";
10+
11+
export type CalendarProps = React.ComponentPropsWithoutRef<typeof DayPicker>;
12+
13+
const CALENDAR_CLASS_NAMES = {
14+
months: "ygi:flex ygi:flex-col",
15+
month: "ygi:flex ygi:flex-col ygi:gap-0",
16+
nav: "ygi:flex ygi:items-center ygi:justify-between ygi:mb-0 ygi:h-6 ygi:w-full",
17+
button_previous:
18+
"ygi:flex ygi:h-6 ygi:w-6 ygi:items-center ygi:justify-center ygi:transition-opacity hover:ygi:opacity-70",
19+
button_next:
20+
"ygi:flex ygi:h-6 ygi:w-6 ygi:items-center ygi:justify-center ygi:transition-opacity hover:ygi:opacity-70",
21+
month_caption:
22+
"ygi:flex ygi:items-center ygi:justify-center ygi:h-6 ygi:flex-1",
23+
caption_label: "ygi:heading-18-sb ygi:text-text-primary ygi:leading-6",
24+
weekdays: twJoin("ygi:mb-2.5 ygi:grid ygi:grid-cols-7 ygi:gap-3"),
25+
weekday: twJoin(
26+
"ygi:flex ygi:items-center ygi:justify-center ygi:px-2",
27+
"ygi:caption-12-rg ygi:text-text-secondary",
28+
),
29+
week: "ygi:grid ygi:grid-cols-7 ygi:gap-3",
30+
day: twJoin(
31+
"ygi:flex ygi:flex-col ygi:items-center ygi:justify-center",
32+
"ygi:mx-auto ygi:size-[34px] ygi:min-w-[34px] ygi:px-2",
33+
),
34+
day_button: twJoin(
35+
"ygi:flex ygi:size-[34px] ygi:items-center ygi:justify-center ygi:px-2 ygi:py-1",
36+
"ygi:heading-18-sb ygi:rounded-full ygi:text-text-primary ygi:transition-colors",
37+
),
38+
selected: "ygi:bg-surface-active ygi:text-text-inverse ygi:rounded-xl",
39+
outside: "ygi:text-text-disabled",
40+
disabled: "ygi:cursor-not-allowed ygi:text-text-disabled",
41+
};
42+
43+
const CalendarDayButton: CustomComponents["DayButton"] = ({
44+
day,
45+
modifiers,
46+
...props
47+
}) => {
48+
// NOTE : react-day-picker 에서는 className 병합에 대한 tailwindCSS 예외 처리가 없어 수동 병합
49+
const buttonClasses = twMerge(
50+
CALENDAR_CLASS_NAMES.day_button,
51+
modifiers.selected && CALENDAR_CLASS_NAMES.selected,
52+
modifiers.outside && CALENDAR_CLASS_NAMES.outside,
53+
modifiers.disabled && CALENDAR_CLASS_NAMES.disabled,
54+
);
55+
56+
return (
57+
<button {...props} type="button" className={buttonClasses}>
58+
{day.date.getDate()}
59+
</button>
60+
);
61+
};
62+
63+
const CalendarNav: CustomComponents["Nav"] = ({
64+
onPreviousClick,
65+
onNextClick,
66+
previousMonth,
67+
nextMonth,
68+
}) => {
69+
// NOTE : Nav 에는 현재 시점의 Month 값이 없어 이전과 이후 값을 활용하여 산정
70+
const referenceMonth = previousMonth ?? nextMonth ?? new Date();
71+
const offset = previousMonth ? 1 : nextMonth ? -1 : 0;
72+
73+
const currentMonth = addMonths(referenceMonth, offset);
74+
const monthText = `${currentMonth.getFullYear()}${currentMonth.getMonth() + 1}월`;
75+
76+
return (
77+
<nav className="ygi:mb-2.5 ygi:flex ygi:h-6 ygi:w-full ygi:items-center ygi:justify-center">
78+
<button
79+
type="button"
80+
disabled={!previousMonth}
81+
onClick={onPreviousClick}
82+
className="hover:ygi:opacity-70 disabled:ygi:opacity-40 ygi:flex ygi:items-center ygi:justify-center ygi:p-1.5 ygi:transition-opacity"
83+
>
84+
<ChevronLeftIcon size={24} />
85+
</button>
86+
<div className="ygi:flex ygi:items-center ygi:justify-center">
87+
<span className="ygi:text-center ygi:heading-18-bd ygi:leading-normal ygi:text-text-primary">
88+
{monthText}
89+
</span>
90+
</div>
91+
<button
92+
type="button"
93+
disabled={!nextMonth}
94+
onClick={onNextClick}
95+
className="hover:ygi:opacity-70 disabled:ygi:opacity-40 ygi:flex ygi:items-center ygi:justify-center ygi:overflow-clip ygi:rounded-lg ygi:p-2.5 ygi:transition-opacity"
96+
>
97+
<ChevronRightIcon size={24} />
98+
</button>
99+
</nav>
100+
);
101+
};
102+
103+
export const Calendar = ({
104+
className,
105+
month: controlledMonth,
106+
onMonthChange,
107+
onDayClick,
108+
...props
109+
}: CalendarProps) => {
110+
const [internalMonth, setInternalMonth] = useState<Date>(
111+
controlledMonth ?? new Date(),
112+
);
113+
114+
// Controlled vs Uncontrolled
115+
const isControlled = controlledMonth !== undefined;
116+
const currentMonth = isControlled ? controlledMonth : internalMonth;
117+
118+
const handleMonthChange = (newMonth: Date) => {
119+
if (!isControlled) {
120+
setInternalMonth(newMonth);
121+
}
122+
onMonthChange?.(newMonth);
123+
};
124+
125+
const handleDayClick: typeof onDayClick = (day, modifiers, e) => {
126+
// 다음 달 또는 이전 달의 날짜를 클릭한 경우
127+
if (modifiers.outside && !isSameMonth(day, currentMonth)) {
128+
handleMonthChange(startOfMonth(day));
129+
}
130+
131+
// 원래의 onDayClick 핸들러 호출
132+
onDayClick?.(day, modifiers, e);
133+
};
134+
135+
return (
136+
<DayPicker
137+
showOutsideDays
138+
fixedWeeks
139+
mode="single"
140+
month={currentMonth}
141+
onMonthChange={handleMonthChange}
142+
onDayClick={handleDayClick}
143+
className={twMerge("ygi:w-full", className)}
144+
classNames={CALENDAR_CLASS_NAMES}
145+
components={{
146+
Nav: CalendarNav,
147+
DayButton: CalendarDayButton,
148+
MonthCaption: () => <></>, // NOTE : Caption을 Nav 안으로 이동했으므로 숨김
149+
}}
150+
{...props}
151+
/>
152+
);
153+
};

src/components/calendar/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Calendar } from "./Calendar";
2+
export type { CalendarProps } from "./Calendar";
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { IconBase, type IconBaseProps } from "../iconBase";
2+
3+
export type ChevronLeftIconProps = Omit<IconBaseProps, "children">;
4+
5+
export const ChevronLeftIcon = ({
6+
size = 16,
7+
...props
8+
}: ChevronLeftIconProps) => {
9+
return (
10+
<IconBase size={size} viewBox="0 0 16 16" {...props}>
11+
<path
12+
d="M9.5999 11.2L6.3999 8L9.5999 4.8"
13+
strokeWidth="1.6"
14+
strokeLinecap="round"
15+
strokeLinejoin="round"
16+
/>
17+
</IconBase>
18+
);
19+
};

src/icons/chevronLeftIcon/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ChevronLeftIcon } from "./ChevronLeftIcon";

src/icons/chevronRightIcon/ChevronRightIcon.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ export const ChevronRightIcon = ({
77
...props
88
}: ChevronRightIconProps) => {
99
return (
10-
<IconBase size={size} viewBox="0 0 24 24" {...props}>
10+
<IconBase size={size} viewBox="0 0 16 16" {...props}>
1111
<path
12-
d="M9.5999 7.19995L14.3999 12L9.5999 16.8"
13-
strokeWidth="2"
12+
d="M6.4001 4.8L9.6001 8L6.4001 11.2"
13+
strokeWidth="1.6"
1414
strokeLinecap="round"
1515
strokeLinejoin="round"
1616
/>

src/pageComponents/gathering/create/DateStep.tsx

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import { trackStepComplete } from "#/components/analytics";
77
import { Layout } from "#/components/layout";
88
import { StepIndicator } from "#/components/stepIndicator";
99
import { Button } from "#/components/button";
10-
import { InputField } from "#/components/inputField";
1110
import { Chip } from "#/components/chip";
12-
import { formatDateInput, isValidDateFormat } from "#/utils/gathering/create";
11+
import { isValidDateFormat } from "#/utils/gathering/create";
1312
import type { CreateMeetingFormSchema } from "#/schemas/gathering";
1413
import type { TimeSlot } from "#/types/gathering";
1514

15+
import { ScheduledDatePicker } from "./ScheduledDatePicker";
16+
1617
const TIME_SLOT_LABEL: Record<TimeSlot, string> = {
1718
LUNCH: "점심",
1819
DINNER: "저녁",
@@ -21,28 +22,11 @@ const TIME_SLOT_LABEL: Record<TimeSlot, string> = {
2122
export const DateStepContent = () => {
2223
const { control } = useFormContext<CreateMeetingFormSchema>();
2324

24-
const {
25-
field: scheduledDateField,
26-
fieldState: { error: scheduledDateError },
27-
} = useController({
28-
control,
29-
name: "scheduledDate",
30-
});
31-
3225
const { field: timeSlotField } = useController({
3326
control,
3427
name: "timeSlot",
3528
});
3629

37-
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
38-
const formatted = formatDateInput(e.target.value);
39-
scheduledDateField.onChange(formatted);
40-
};
41-
42-
const handleDateClear = () => {
43-
scheduledDateField.onChange("");
44-
};
45-
4630
const handleTimeSlotChange = (slot: TimeSlot) => {
4731
timeSlotField.onChange(slot === timeSlotField.value ? null : slot);
4832
};
@@ -55,16 +39,7 @@ export const DateStepContent = () => {
5539
<h1 className="ygi:heading-22-bd ygi:text-text-primary">
5640
약속 날짜를 입력해 주세요
5741
</h1>
58-
<InputField
59-
placeholder="날짜를 입력해주세요"
60-
helperText="예) 2026.01.28"
61-
errorText={scheduledDateError?.message}
62-
inputMode="numeric"
63-
showClearButton
64-
value={scheduledDateField.value || ""}
65-
onChange={handleDateChange}
66-
onClear={handleDateClear}
67-
/>
42+
<ScheduledDatePicker />
6843
</div>
6944

7045
<div className="ygi:flex ygi:flex-col ygi:gap-xl ygi:px-6">

0 commit comments

Comments
 (0)