feat: SwipeableView 스와이프 탭 전환 컴포넌트
This commit is contained in:
79
src/components/SwipeableView.css
Normal file
79
src/components/SwipeableView.css
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/* SwipeableView — swipeable tab container */
|
||||||
|
|
||||||
|
.swipeable-view {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab bar */
|
||||||
|
.swipeable-view__tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-line);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-view__tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual tab button */
|
||||||
|
.swipeable-view__tab {
|
||||||
|
flex: 1;
|
||||||
|
min-width: fit-content;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: calc(var(--radius-md) - 2px);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.18s var(--ease-out), background 0.18s var(--ease-out);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-view__tab.is-active {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sliding track */
|
||||||
|
.swipeable-view__track {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
transition: transform 0.3s var(--ease-out);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-view__track.is-swiping {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each panel */
|
||||||
|
.swipeable-view__panel {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.swipeable-view__track {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-view__tab {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/components/SwipeableView.jsx
Normal file
92
src/components/SwipeableView.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user