Skip to content

Commit 2a5c1b0

Browse files
committed
feat: 날짜피커 커스텀 생성
1 parent d9bdc5a commit 2a5c1b0

File tree

2 files changed

+245
-26
lines changed

2 files changed

+245
-26
lines changed
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
'use client';
2+
3+
import { cn } from '@/lib/utils';
4+
import * as React from 'react';
5+
6+
const ITEM_HEIGHT = 32;
7+
const VISIBLE_ITEMS = 7;
8+
const CONTAINER_HEIGHT = ITEM_HEIGHT * VISIBLE_ITEMS; // 224px
9+
10+
interface PickerColumnProps {
11+
items: number[];
12+
value: number;
13+
label: string;
14+
onChange: (value: number) => void;
15+
}
16+
17+
function PickerColumn({ items, value, label, onChange }: PickerColumnProps) {
18+
const containerRef = React.useRef<HTMLDivElement>(null);
19+
const isScrollingRef = React.useRef(false);
20+
const scrollTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
21+
22+
// Calculate padding to center first/last items
23+
const paddingItems = Math.floor(VISIBLE_ITEMS / 2); // 3 items padding top/bottom
24+
25+
// Scroll to value on mount and when value changes externally
26+
React.useEffect(() => {
27+
if (containerRef.current && !isScrollingRef.current) {
28+
const index = items.indexOf(value);
29+
if (index !== -1) {
30+
containerRef.current.scrollTop = index * ITEM_HEIGHT;
31+
}
32+
}
33+
}, [value, items]);
34+
35+
const handleScroll = () => {
36+
if (!containerRef.current) return;
37+
38+
// Clear existing timeout
39+
if (scrollTimeoutRef.current) {
40+
clearTimeout(scrollTimeoutRef.current);
41+
}
42+
43+
isScrollingRef.current = true;
44+
45+
// Debounce: wait for scroll to stop
46+
scrollTimeoutRef.current = setTimeout(() => {
47+
if (!containerRef.current) return;
48+
49+
const scrollTop = containerRef.current.scrollTop;
50+
const index = Math.round(scrollTop / ITEM_HEIGHT);
51+
const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
52+
53+
// Snap to exact position
54+
containerRef.current.scrollTop = clampedIndex * ITEM_HEIGHT;
55+
56+
const newValue = items[clampedIndex];
57+
if (newValue !== undefined && newValue !== value) {
58+
onChange(newValue);
59+
}
60+
61+
isScrollingRef.current = false;
62+
}, 100);
63+
};
64+
65+
const handleItemClick = (index: number) => {
66+
if (containerRef.current) {
67+
containerRef.current.scrollTo({
68+
top: index * ITEM_HEIGHT,
69+
behavior: 'smooth',
70+
});
71+
}
72+
};
73+
74+
return (
75+
<div className='relative h-[224px] overflow-hidden'>
76+
<div
77+
ref={containerRef}
78+
className='scrollbar-hide h-full overflow-y-auto scroll-smooth'
79+
onScroll={handleScroll}
80+
style={{
81+
scrollSnapType: 'y mandatory',
82+
}}
83+
>
84+
{/* Top padding spacer */}
85+
<div style={{ height: paddingItems * ITEM_HEIGHT }} />
86+
87+
{items.map((item, index) => (
88+
<div
89+
key={item}
90+
className='flex cursor-pointer items-center justify-center'
91+
style={{
92+
height: ITEM_HEIGHT,
93+
scrollSnapAlign: 'center',
94+
}}
95+
onClick={() => handleItemClick(index)}
96+
>
97+
<div
98+
className={cn(
99+
'typo-heading-sm-medium whitespace-nowrap transition-colors duration-200',
100+
value === item ? 'text-text-basic' : 'text-text-disabled',
101+
)}
102+
>
103+
{item}
104+
{label}
105+
</div>
106+
</div>
107+
))}
108+
109+
{/* Bottom padding spacer */}
110+
<div style={{ height: paddingItems * ITEM_HEIGHT }} />
111+
</div>
112+
</div>
113+
);
114+
}
115+
116+
interface ScrollableDatePickerProps {
117+
value?: Date;
118+
onChange: (date: Date) => void;
119+
minDate?: Date;
120+
maxDate?: Date;
121+
}
122+
123+
export function ScrollableDatePicker({
124+
value,
125+
onChange,
126+
minDate,
127+
maxDate,
128+
}: ScrollableDatePickerProps) {
129+
const currentDate = value || new Date();
130+
131+
const selectedYear = currentDate.getFullYear();
132+
const selectedMonth = currentDate.getMonth() + 1;
133+
const selectedDay = currentDate.getDate();
134+
135+
// Generate years
136+
const currentYear = new Date().getFullYear();
137+
const minYear = minDate?.getFullYear() || currentYear - 50;
138+
const maxYear = maxDate?.getFullYear() || currentYear + 50;
139+
140+
const years = React.useMemo(
141+
() => Array.from({ length: maxYear - minYear + 1 }, (_, i) => minYear + i),
142+
[minYear, maxYear],
143+
);
144+
145+
const months = React.useMemo(
146+
() => Array.from({ length: 12 }, (_, i) => i + 1),
147+
[],
148+
);
149+
150+
const getDaysInMonth = (year: number, month: number) => {
151+
return new Date(year, month, 0).getDate();
152+
};
153+
154+
const days = React.useMemo(
155+
() =>
156+
Array.from(
157+
{ length: getDaysInMonth(selectedYear, selectedMonth) },
158+
(_, i) => i + 1,
159+
),
160+
[selectedYear, selectedMonth],
161+
);
162+
163+
const handleYearChange = (year: number) => {
164+
const daysInNewMonth = getDaysInMonth(year, selectedMonth);
165+
const newDay = Math.min(selectedDay, daysInNewMonth);
166+
onChange(new Date(year, selectedMonth - 1, newDay));
167+
};
168+
169+
const handleMonthChange = (month: number) => {
170+
const daysInNewMonth = getDaysInMonth(selectedYear, month);
171+
const newDay = Math.min(selectedDay, daysInNewMonth);
172+
onChange(new Date(selectedYear, month - 1, newDay));
173+
};
174+
175+
const handleDayChange = (day: number) => {
176+
onChange(new Date(selectedYear, selectedMonth - 1, day));
177+
};
178+
179+
return (
180+
<div className='relative mx-auto flex h-[224px] w-full max-w-sm items-center justify-center overflow-hidden'>
181+
{/* Selection Indicator */}
182+
<div
183+
className='pointer-events-none absolute inset-x-4 top-1/2 z-0 h-[32px] -translate-y-1/2 rounded-[6px]'
184+
style={{ backgroundColor: 'var(--color-element-gray-light, #F1F2F3)' }}
185+
/>
186+
187+
{/* Columns Container */}
188+
<div className='z-10 flex h-full w-full justify-center px-4'>
189+
<div className='relative h-full min-w-0 flex-1'>
190+
<PickerColumn
191+
items={years}
192+
value={selectedYear}
193+
label='년'
194+
onChange={handleYearChange}
195+
/>
196+
</div>
197+
<div className='relative h-full min-w-0 flex-1'>
198+
<PickerColumn
199+
items={months}
200+
value={selectedMonth}
201+
label='월'
202+
onChange={handleMonthChange}
203+
/>
204+
</div>
205+
<div className='relative h-full min-w-0 flex-1'>
206+
<PickerColumn
207+
items={days}
208+
value={selectedDay}
209+
label='일'
210+
onChange={handleDayChange}
211+
/>
212+
</div>
213+
</div>
214+
</div>
215+
);
216+
}

src/global/components/DateXInput.tsx

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
'use client';
2-
import { Calendar } from '@/components/ui/calendar';
2+
33
import {
4-
Popover,
5-
PopoverContent,
6-
PopoverTrigger,
7-
} from '@/components/ui/popover';
4+
Drawer,
5+
DrawerContent,
6+
DrawerHeader,
7+
DrawerTitle,
8+
DrawerTrigger,
9+
} from '@/components/ui/drawer';
10+
import { ScrollableDatePicker } from '@/components/ui/scrollable-date-picker';
811
import { cn } from '@/lib/utils';
912
import { format } from 'date-fns';
1013
import { CalendarIcon } from 'lucide-react';
@@ -52,8 +55,8 @@ export default function DateXInput({
5255
{label}
5356
</div>
5457
)}
55-
<Popover open={open} onOpenChange={setOpen}>
56-
<PopoverTrigger asChild>
58+
<Drawer open={open} onOpenChange={setOpen}>
59+
<DrawerTrigger asChild>
5760
<button
5861
type='button'
5962
disabled={disabled}
@@ -70,25 +73,25 @@ export default function DateXInput({
7073
</span>
7174
<CalendarIcon className='text-text-subtler ml-2 h-5 w-5' />
7275
</button>
73-
</PopoverTrigger>
74-
<PopoverContent
75-
align='start'
76-
className='border-none bg-transparent p-0 shadow-none'
77-
>
78-
<Calendar
79-
mode='single'
80-
selected={parsedDate}
81-
onSelect={(date: Date | undefined) => {
82-
if (date) {
83-
onChange(format(date, 'yyyy-MM-dd'));
84-
setOpen(false);
85-
}
86-
}}
87-
fromDate={minDate}
88-
toDate={maxDate}
89-
/>
90-
</PopoverContent>
91-
</Popover>
76+
</DrawerTrigger>
77+
<DrawerContent className='bg-background-white'>
78+
<div className='mx-auto w-full max-w-sm'>
79+
<DrawerHeader>
80+
<DrawerTitle className='text-center'>날짜 선택</DrawerTitle>
81+
</DrawerHeader>
82+
<div className='p-4 pb-8' data-vaul-no-drag>
83+
<ScrollableDatePicker
84+
value={parsedDate}
85+
onChange={(date: Date) => {
86+
onChange(format(date, 'yyyy-MM-dd'));
87+
}}
88+
minDate={minDate}
89+
maxDate={maxDate}
90+
/>
91+
</div>
92+
</div>
93+
</DrawerContent>
94+
</Drawer>
9295
{(error || helperText) && (
9396
<div
9497
className={`typo-caption-sm-medium px-2 ${

0 commit comments

Comments
 (0)