diff --git a/src/components/SwipeableView.css b/src/components/SwipeableView.css new file mode 100644 index 0000000..7cb9941 --- /dev/null +++ b/src/components/SwipeableView.css @@ -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; + } +} diff --git a/src/components/SwipeableView.jsx b/src/components/SwipeableView.jsx new file mode 100644 index 0000000..e86549a --- /dev/null +++ b/src/components/SwipeableView.jsx @@ -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 ( +
+ {showTabs && ( +
+ {tabs.map((tab, i) => ( + + ))} +
+ )} +
+ {tabs.map((tab, i) => ( +
+ {tab.content} +
+ ))} +
+
+ ); +}