feat: SwipeableView 스와이프 탭 전환 컴포넌트

This commit is contained in:
2026-04-23 14:36:35 +09:00
parent 2db0c1b3eb
commit 6875a28e92
2 changed files with 171 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
import { useState, useRef } from 'react';
import { useSwipeable } from 'react-swipeable';
import { useIsMobile } from '../hooks/useIsMobile';
import './SwipeableView.css';
export default function SwipeableView({
tabs = [],
activeIndex: controlledIndex,
onTabChange,
showTabs = true,
}) {
const isMobile = useIsMobile();
const [internalIndex, setInternalIndex] = useState(0);
const [swipeOffset, setSwipeOffset] = useState(0);
const [isSwiping, setIsSwiping] = useState(false);
const trackRef = useRef(null);
const activeIndex = controlledIndex !== undefined ? controlledIndex : internalIndex;
const setIndex = (idx) => {
const clamped = Math.max(0, Math.min(tabs.length - 1, idx));
if (controlledIndex === undefined) setInternalIndex(clamped);
onTabChange?.(clamped);
};
const handlers = useSwipeable({
onSwiping: ({ deltaX }) => {
if (!isMobile) return;
setIsSwiping(true);
setSwipeOffset(deltaX);
},
onSwipedLeft: ({ deltaX }) => {
if (!isMobile) return;
setIsSwiping(false);
setSwipeOffset(0);
if (Math.abs(deltaX) > 30) setIndex(activeIndex + 1);
},
onSwipedRight: ({ deltaX }) => {
if (!isMobile) return;
setIsSwiping(false);
setSwipeOffset(0);
if (Math.abs(deltaX) > 30) setIndex(activeIndex - 1);
},
onTouchEndOrOnMouseUp: () => {
setIsSwiping(false);
setSwipeOffset(0);
},
trackMouse: false,
trackTouch: true,
delta: 30,
preventScrollOnSwipe: false,
});
const trackTranslate = -activeIndex * 100 + (isSwiping ? (swipeOffset / (trackRef.current?.offsetWidth || 1)) * 100 : 0);
return (
<div className="swipeable-view">
{showTabs && (
<div className="swipeable-view__tabs" role="tablist">
{tabs.map((tab, i) => (
<button
key={tab.key}
role="tab"
aria-selected={i === activeIndex}
className={`swipeable-view__tab ${i === activeIndex ? 'is-active' : ''}`}
onClick={() => setIndex(i)}
>
{tab.label}
</button>
))}
</div>
)}
<div
{...(isMobile ? handlers : {})}
ref={trackRef}
className={`swipeable-view__track ${isSwiping ? 'is-swiping' : ''}`}
style={{ transform: `translateX(${trackTranslate}%)` }}
>
{tabs.map((tab, i) => (
<div
key={tab.key}
role="tabpanel"
aria-hidden={i !== activeIndex}
className="swipeable-view__panel"
>
{tab.content}
</div>
))}
</div>
</div>
);
}