From 6875a28e9287184924eb87ae895fae1cd31e0297 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 23 Apr 2026 14:36:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SwipeableView=20=EC=8A=A4=EC=99=80?= =?UTF-8?q?=EC=9D=B4=ED=94=84=20=ED=83=AD=20=EC=A0=84=ED=99=98=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SwipeableView.css | 79 +++++++++++++++++++++++++++ src/components/SwipeableView.jsx | 92 ++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 src/components/SwipeableView.css create mode 100644 src/components/SwipeableView.jsx 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} +
+ ))} +
+
+ ); +}