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