Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
01f3dc8
🔧chore: merge 후 conflict 해결
gummmmmy0v0 Oct 7, 2025
45c2cef
♻️ refactor: CalendarHeader 분리 #21
gummmmmy0v0 Oct 7, 2025
33a19da
♻️ refactor: Calender DayViewMode 분리 #21
gummmmmy0v0 Oct 7, 2025
f14b77f
♻️ refactor: MonthViewMode 분리 #21
gummmmmy0v0 Oct 7, 2025
a1dbc8b
♻️ refactor: YearViewMode 분리 #21
gummmmmy0v0 Oct 7, 2025
43bdeb0
♻️ refactor: Calendar 수정 #21
gummmmmy0v0 Oct 7, 2025
e60756e
♻️ refactor: 타입, 인터페이스 선언 파일 변경 #21
gummmmmy0v0 Oct 7, 2025
ecf66c8
🔧chore: index.ts 추가 #21
gummmmmy0v0 Oct 7, 2025
3780eba
✨feat: TimeCalendar 기능 추가 #21
gummmmmy0v0 Oct 8, 2025
0d13ecc
♻️ refactor: currentMonth -> BaseCalendarProps 확장 #21
gummmmmy0v0 Oct 8, 2025
ff513ab
♻️ refactor: onSelect 제네릭 타입 이용 #21
gummmmmy0v0 Oct 8, 2025
7d1cee5
♻️ refactor: getDaysInMonth 함수 fillCalendarDays 함수로 변경 null 대신 지난달 다음…
gummmmmy0v0 Oct 8, 2025
3ced8df
♻️ refactor: 지난달, 다음달 날짜 회색 컬러 #21
gummmmmy0v0 Oct 8, 2025
1601e62
♻️ refactor: getTime -> timeFormatter로 parsedDateTime, formatDateTime…
gummmmmy0v0 Oct 8, 2025
a47a187
✨feat: DateInput / TimeInput 분리 #21
gummmmmy0v0 Oct 8, 2025
ea3693c
✨feat: TimeInput 타이핑 입력 기능 추가 #21
gummmmmy0v0 Oct 8, 2025
a610ee4
✨feat: 현재 날짜 이후 선택 불가, 타이핑 불가 #21
gummmmmy0v0 Oct 9, 2025
ffe1c47
✨feat: useToggle, useClickOutside 훅 사용 #21
gummmmmy0v0 Oct 9, 2025
cb08537
♻️ refactor: selectMode에 따른 headerLabel 수정 #21
gummmmmy0v0 Oct 9, 2025
5bb75c6
♻️ refactor: 코드 리팩토링 #21
gummmmmy0v0 Oct 9, 2025
a0891ca
♻️ refactor: 캘린더 리팩토링 #21
gummmmmy0v0 Oct 9, 2025
35f5a1c
🐛fix: TimeInput 스토리북 불필요한 props 제거 #21
gummmmmy0v0 Oct 9, 2025
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
95 changes: 95 additions & 0 deletions src/components/ui/calendar/Calendar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
CalendarHeader,
DayViewMode,
MonthViewMode,
YearViewMode,
} from '@/components/ui/calendar/';
import { CalendarProps, SelectMode } from '@/types/calendar';
import { useState } from 'react';

export default function Calendar({ value, onSelect }: CalendarProps) {
const [currentDay, setCurrentDay] = useState<Date>(value ?? new Date());
const [currentMonth, setCurrentMonth] = useState<Date>(value ?? new Date());
const [selectMode, setSelectMode] = useState<SelectMode>('day');

const TODAY = new Date();
TODAY.setHours(0, 0, 0, 0);

const handleSelect = (date: Date) => {
const selectedDate = new Date(date);
selectedDate.setHours(0, 0, 0, 0);

if (selectedDate < TODAY) {
return;
}
setCurrentDay(date);
setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1));
onSelect?.(date);
};

// day 한 달 단위, year 10년 단위, month 1년 단위
const onChange = (offset: number) => {
if (selectMode === 'day') {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + offset, 1));
} else if (selectMode === 'year') {
setCurrentMonth(
new Date(currentMonth.getFullYear() + offset * 10, currentMonth.getMonth(), 1)
);
} else {
setCurrentMonth(new Date(currentMonth.getFullYear() + offset, currentMonth.getMonth(), 1));
}
};

// 오늘로 이동
const handleToday = () => {
setCurrentMonth(TODAY);
setCurrentDay(TODAY);
setSelectMode('day');
onSelect?.(TODAY);
};

// 모드 전환
const onToggleMode = () => {
setSelectMode(prev => (prev === 'day' ? 'month' : prev === 'month' ? 'year' : 'day'));
};

return (
<div className='mt-3 w-80 rounded-xl border bg-white p-4'>
<CalendarHeader
selectMode={selectMode}
currentMonth={currentMonth}
onToggleMode={onToggleMode}
onChange={onChange}
/>

{selectMode === 'day' && (
<DayViewMode currentMonth={currentMonth} currentDay={currentDay} onSelect={handleSelect} />
)}

{selectMode === 'month' && (
<MonthViewMode
onSelect={month => {
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
setSelectMode('day');
}}
/>
)}

{selectMode === 'year' && (
<YearViewMode
currentMonth={currentMonth}
onSelect={year => {
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
setSelectMode('month');
}}
/>
)}

<div className='mt-3 text-right'>
<button onClick={handleToday} className='text-sm text-blue-200 hover:underline'>
오늘로 이동
</button>
</div>
</div>
);
}
48 changes: 48 additions & 0 deletions src/components/ui/calendar/CalendarHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Icon } from '@/components/ui/icon';
import { CalendarHeaderProps } from '@/types/calendar';
import { clsx } from 'clsx';
import { useMemo } from 'react';

export default function CalendarHeader({
selectMode,
currentMonth,
onToggleMode,
onChange,
}: CalendarHeaderProps) {
const CALENDAR_ARROW_CLASS = clsx('rounded px-1 pt-1 hover:bg-gray-100');

const headerLabel = useMemo(() => {
switch (selectMode) {
case 'day':
return currentMonth.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
});
case 'month':
return `${currentMonth.getFullYear()}년`;
case 'year': {
const startYear = Math.floor(currentMonth.getFullYear() / 10) * 10;
const endYear = startYear + 9;
return `${startYear} - ${endYear}`;
}
default:
return ' ';
}
}, [selectMode, currentMonth]);

return (
<div className='mb-3 flex items-center justify-between'>
<button onClick={() => onChange(-1)} className={CALENDAR_ARROW_CLASS}>
<Icon iconName='chevronLeft' iconSize='md' ariaLabel='이전으로 이동' />
</button>

<button onClick={onToggleMode} className='text-lg font-semibold hover:underline'>
{headerLabel}
</button>

<button onClick={() => onChange(1)} className={CALENDAR_ARROW_CLASS}>
<Icon iconName='chevronRight' iconSize='md' ariaLabel='다음으로 이동' />
</button>
</div>
);
}
68 changes: 68 additions & 0 deletions src/components/ui/calendar/DayViewMode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { cn } from '@/lib/utils/cn';
import { fillCalendarDays } from '@/lib/utils/fillCalendarDays';
import { DayViewProps } from '@/types/calendar';
import { clsx } from 'clsx';

export default function DayViewMode({ currentMonth, currentDay, onSelect }: DayViewProps) {
const DAYS = fillCalendarDays(currentMonth.getFullYear(), currentMonth.getMonth());
const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'];

const TODAY = new Date();
TODAY.setHours(0, 0, 0, 0);

const DAY_CALENDAR_CLASS = clsx('text-md grid grid-cols-7 text-center');

return (
<>
<div className={`${DAY_CALENDAR_CLASS} mb-3 font-medium text-gray-500`}>
{WEEKDAYS.map((day, i) => {
const headerClass = i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-200' : '';

return (
<div key={day} className={headerClass}>
{day}
</div>
);
})}
</div>

<div className={`${DAY_CALENDAR_CLASS} gap-1`}>
{DAYS.map((dayObj, i) => {
const { date, isCurrentMonth } = dayObj;

const isDisabled = date < TODAY;
const isSelected = date.toDateString() === currentDay.toDateString();
const dayOfWeek = date.getDay();

const DAY_CELL_CLASS = isDisabled
? 'text-gray-500'
: isCurrentMonth && dayOfWeek === 0
? 'text-red-400'
: isCurrentMonth && dayOfWeek === 6
? 'text-blue-200'
: '';

return (
<button
key={i}
onClick={() => !isDisabled && onSelect(date)}
disabled={isDisabled}
className={cn(
'rounded-lg py-1.5 transition',
isSelected
? 'bg-blue-200 font-semibold text-white'
: !isDisabled
? 'hover:bg-blue-100'
: '',
DAY_CELL_CLASS,
!isDisabled && !isCurrentMonth && 'text-gray-400'
)}
>
{date.getDate()}
</button>
);
})}
</div>
</>
);
}
17 changes: 17 additions & 0 deletions src/components/ui/calendar/MonthViewMode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { MonthViewProps } from '@/types/calendar';

export default function MonthViewMode({ onSelect: onSelectMonth }: MonthViewProps) {
return (
<div className='grid grid-cols-3 gap-2 text-center'>
{Array.from({ length: 12 }).map((_, i) => (
<button
key={i}
onClick={() => onSelectMonth(i)}
className='rounded-lg py-2 hover:bg-blue-100'
>
{i + 1}월
</button>
))}
</div>
);
}
8 changes: 8 additions & 0 deletions src/components/ui/calendar/TimeSelector.styles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from 'react';

const TIME_SCROLL_CLASS =
'flex max-h-[104px] flex-col gap-4 overflow-y-auto rounded border p-2 text-lg';

export const ScrollList: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className={TIME_SCROLL_CLASS}>{children}</div>
);
96 changes: 96 additions & 0 deletions src/components/ui/calendar/TimeSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { cn } from '@/lib/utils/cn';
import { Period, TimeSelectorProps } from '@/types/calendar';
import { useState } from 'react';
import { ScrollList } from './TimeSelector.styles';

export default function TimeSelector({ onSelect, period, hours, minutes }: TimeSelectorProps) {
const [currentPeriod, setCurrentPeriod] = useState<Period>(period);
const [currentHour, setCurrentHour] = useState(hours);
const [currentMinute, setCurrentMinute] = useState(minutes);

// 01 ~ 12
const hoursList = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0'));
// 00 ~ 59
const minutesList = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));

const notifySelect = (p: Period, h: string, m: string) => {
onSelect?.(`${p} ${h}:${m}`);
};

const handleSelect = (type: 'period' | 'hour' | 'minute', value: string) => {
const updates = {
period: () => setCurrentPeriod(value as Period),
hour: () => setCurrentHour(value),
minute: () => setCurrentMinute(value),
};

updates[type]();

notifySelect(
type === 'period' ? (value as Period) : currentPeriod,
type === 'hour' ? value : currentHour,
type === 'minute' ? value : currentMinute
);
};

const TIME_SELECTOR_WRAPPER_CLASS =
'mt-3 flex w-80 items-center justify-center gap-6 rounded-lg border bg-white p-4';

const BASE_PERIOD_CLASS = 'rounded-lg px-4 py-2 font-semibold transition';
const BASE_TIME_CLASS = 'rounded px-3 py-1 transition';

const selectPeriodClass = (p: Period) =>
cn(
BASE_PERIOD_CLASS,
currentPeriod === p ? 'bg-blue-200 text-white' : 'bg-gray-100 hover:bg-gray-200'
);

const selectTimeClass = (value: string, currentValue: string) =>
cn(BASE_TIME_CLASS, currentValue === value ? 'bg-blue-200 text-white' : 'hover:bg-gray-100');

return (
<div className={TIME_SELECTOR_WRAPPER_CLASS}>
<div className='flex flex-col gap-4 p-2'>
{['오전', '오후'].map(p => (
<button
key={p}
className={selectPeriodClass(p as Period)}
onClick={() => handleSelect('period', p)}
>
{p}
</button>
))}
</div>

<div className='flex gap-4'>
<div>
<ScrollList>
{hoursList.map(h => (
<button
key={h}
className={selectTimeClass(h, currentHour)}
onClick={() => handleSelect('hour', h)}
>
{h}
</button>
))}
</ScrollList>
</div>

<div>
<ScrollList>
{minutesList.map(m => (
<button
key={m}
className={selectTimeClass(m, currentMinute)}
onClick={() => handleSelect('minute', m)}
>
{m}
</button>
))}
</ScrollList>
</div>
</div>
</div>
);
}
22 changes: 22 additions & 0 deletions src/components/ui/calendar/YearViewMode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { YearViewProps } from '@/types/calendar';

export default function YearViewMode({ currentMonth, onSelect }: YearViewProps) {
const START_YEAR = Math.floor(currentMonth.getFullYear() / 10) * 10;

return (
<div className='grid grid-cols-3 gap-2 text-center'>
{Array.from({ length: 10 }).map((_, i) => {
const YEAR = START_YEAR + i;
return (
<button
key={YEAR}
onClick={() => onSelect(YEAR)}
className='rounded-lg py-2 hover:bg-blue-100'
>
{YEAR}
</button>
);
})}
</div>
);
}
6 changes: 6 additions & 0 deletions src/components/ui/calendar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { default as Calendar } from '@/components/ui/calendar/Calendar';
export { default as CalendarHeader } from '@/components/ui/calendar/CalendarHeader';
export { default as DayViewMode } from '@/components/ui/calendar/DayViewMode';
export { default as MonthViewMode } from '@/components/ui/calendar/MonthViewMode';
export { default as TimeSelector } from '@/components/ui/calendar/TimeSelector';
export { default as YearViewMode } from '@/components/ui/calendar/YearViewMode';
10 changes: 9 additions & 1 deletion src/components/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
export {
Calendar,
CalendarHeader,
DayViewMode,
MonthViewMode,
TimeSelector,
YearViewMode,
} from '@/components/ui/calendar';
export { DateInput, Input, TimeInput } from '@/components/ui/input';
export { Table } from '@/components/ui/table';
export { Button } from './button';
export { Dropdown } from './dropdown';
export { Icon } from './icon';
export { Input } from './input';
export { Modal, Notification } from './modal';
export { Post } from './post';
Loading