33import Button from '@/components/Button' ;
44import { ACTIVITY_CATEGORIES , ActivityCategory } from '@/constants/categories' ;
55import cn from '@/lib/cn' ;
6+ import { useRef , useState , useEffect } from 'react' ;
67
78interface CategoryFilterProps {
89 selectedCategory : ActivityCategory ;
@@ -15,20 +16,78 @@ export default function CategoryFilter({
1516 onChange,
1617 className,
1718} : CategoryFilterProps ) {
19+ const scrollRef = useRef < HTMLDivElement | null > ( null ) ;
20+ const [ hasInteracted , setHasInteracted ] = useState ( false ) ;
21+ const [ isDragging , setIsDragging ] = useState ( false ) ;
22+ const startX = useRef ( 0 ) ;
23+ const scrollLeft = useRef ( 0 ) ;
24+
25+ const handleFirstInteraction = ( ) => {
26+ if ( ! hasInteracted ) setHasInteracted ( true ) ;
27+ } ;
28+
29+ const handleMouseDown = ( e : React . MouseEvent < HTMLDivElement > ) => {
30+ setIsDragging ( true ) ;
31+ startX . current = e . pageX - ( scrollRef . current ?. offsetLeft ?? 0 ) ;
32+ scrollLeft . current = scrollRef . current ?. scrollLeft ?? 0 ;
33+ handleFirstInteraction ( ) ;
34+ } ;
35+
36+ const handleMouseMove = ( e : React . MouseEvent < HTMLDivElement > ) => {
37+ if ( ! isDragging || ! scrollRef . current ) return ;
38+ e . preventDefault ( ) ;
39+ const x = e . pageX - scrollRef . current . offsetLeft ;
40+ const walk = ( x - startX . current ) * 1 ;
41+ scrollRef . current . scrollLeft = scrollLeft . current - walk ;
42+ handleFirstInteraction ( ) ;
43+ } ;
44+
45+ const handleMouseUpOrLeave = ( ) => {
46+ setIsDragging ( false ) ;
47+ } ;
48+
49+ const handleScroll = ( ) => {
50+ handleFirstInteraction ( ) ;
51+ } ;
52+
53+ useEffect ( ( ) => {
54+ const el = scrollRef . current ;
55+ if ( ! el ) return ;
56+ el . addEventListener ( 'scroll' , handleScroll ) ;
57+ return ( ) => el . removeEventListener ( 'scroll' , handleScroll ) ;
58+ } , [ ] ) ;
59+
1860 return (
19- < div className = { cn ( 'relative flex w-full gap-8 overflow-x-auto whitespace-nowrap no-scrollbar' , className ) } >
20- { ACTIVITY_CATEGORIES . map ( ( category ) => (
21- < Button
22- key = { category }
23- className = 'flex-shrink-0 max-w-80 max-h-41 py-12 text-[16px] rounded-[15px]'
24- selected = { selectedCategory === category }
25- variant = 'category'
26- onClick = { ( ) => onChange ( category ) }
27- >
28- { category }
29- </ Button >
30- ) ) }
31- < div className = 'pointer-events-none absolute top-0 right-0 h-full w-100 bg-gradient-to-l from-white to-transparent' />
61+ < div className = 'relative w-full' >
62+ { /* 스크롤 가능한 영역 */ }
63+ < div
64+ ref = { scrollRef }
65+ className = { cn (
66+ 'relative z-20 flex w-full gap-8 pr-15 overflow-x-auto whitespace-nowrap no-scrollbar cursor-grab active:cursor-grabbing select-none' ,
67+ className ,
68+ ) }
69+ onMouseDown = { handleMouseDown }
70+ onMouseMove = { handleMouseMove }
71+ onMouseUp = { handleMouseUpOrLeave }
72+ onMouseLeave = { handleMouseUpOrLeave }
73+ >
74+ { ACTIVITY_CATEGORIES . map ( ( category ) => (
75+ < Button
76+ key = { category }
77+ className = 'flex-shrink-0 max-w-80 max-h-41 py-12 text-[16px] rounded-[15px]'
78+ selected = { selectedCategory === category }
79+ variant = 'category'
80+ onClick = { ( ) => onChange ( category ) }
81+ >
82+ { category }
83+ </ Button >
84+ ) ) }
85+
86+ { /* 그라데이션: 처음만 보이고 상호작용하면 사라짐 */ }
87+ { ! hasInteracted && (
88+ < div className = 'md:hidden pointer-events-none absolute top-0 right-0 h-full w-[100px] bg-gradient-to-l from-white to-transparent z-10 transition-opacity duration-300' />
89+ ) }
90+ </ div >
3291 </ div >
3392 ) ;
3493}
0 commit comments