|
| 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 | +}; |
0 commit comments