139 lines
4.3 KiB
TypeScript
139 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 좌우 버튼 표시 모드
|
|
*
|
|
* 화살표 버튼으로 아이템을 한 장씩 넘기는 모드
|
|
* 터치 최적화: 최소 44x44px 터치 영역
|
|
*/
|
|
|
|
import React, { useState, useCallback, useRef } from "react";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
|
|
// ===== Props =====
|
|
|
|
export interface ArrowsModeProps {
|
|
/** 총 아이템 수 */
|
|
itemCount: number;
|
|
/** 페이지 인디케이터 표시 여부 */
|
|
showIndicator?: boolean;
|
|
/** 현재 인덱스에 해당하는 아이템 렌더링 */
|
|
renderItem: (index: number) => React.ReactNode;
|
|
}
|
|
|
|
// ===== 메인 컴포넌트 =====
|
|
|
|
/** 스와이프 감지 최소 거리(px) */
|
|
const SWIPE_THRESHOLD = 50;
|
|
|
|
export function ArrowsModeComponent({
|
|
itemCount,
|
|
showIndicator = true,
|
|
renderItem,
|
|
}: ArrowsModeProps) {
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const touchStartX = useRef<number | null>(null);
|
|
const touchStartY = useRef<number | null>(null);
|
|
|
|
const goToPrev = useCallback(() => {
|
|
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1));
|
|
}, [itemCount]);
|
|
|
|
const goToNext = useCallback(() => {
|
|
setCurrentIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0));
|
|
}, [itemCount]);
|
|
|
|
// --- 터치 스와이프 핸들러 ---
|
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
touchStartX.current = e.touches[0].clientX;
|
|
touchStartY.current = e.touches[0].clientY;
|
|
}, []);
|
|
|
|
const handleTouchEnd = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
if (touchStartX.current === null || touchStartY.current === null) return;
|
|
|
|
const deltaX = e.changedTouches[0].clientX - touchStartX.current;
|
|
const deltaY = e.changedTouches[0].clientY - touchStartY.current;
|
|
|
|
// 수평 스와이프가 수직보다 클 때만 페이지 전환 (스크롤과 구분)
|
|
if (
|
|
Math.abs(deltaX) > SWIPE_THRESHOLD &&
|
|
Math.abs(deltaX) > Math.abs(deltaY)
|
|
) {
|
|
if (deltaX < 0) {
|
|
goToNext(); // 왼쪽 스와이프 -> 다음
|
|
} else {
|
|
goToPrev(); // 오른쪽 스와이프 -> 이전
|
|
}
|
|
}
|
|
|
|
touchStartX.current = null;
|
|
touchStartY.current = null;
|
|
},
|
|
[goToNext, goToPrev]
|
|
);
|
|
|
|
if (itemCount === 0) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<span className="text-xs text-muted-foreground">아이템 없음</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="relative h-full w-full"
|
|
onTouchStart={handleTouchStart}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
{/* 아이템 (전체 영역 사용) */}
|
|
<div className="h-full w-full">
|
|
{renderItem(currentIndex)}
|
|
</div>
|
|
|
|
{/* 좌우 화살표 (콘텐츠 위에 겹침) */}
|
|
{itemCount > 1 && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={goToPrev}
|
|
className="absolute left-1 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-background/70 shadow-sm backdrop-blur-sm transition-all hover:bg-background/90 active:scale-95"
|
|
aria-label="이전"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={goToNext}
|
|
className="absolute right-1 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-background/70 shadow-sm backdrop-blur-sm transition-all hover:bg-background/90 active:scale-95"
|
|
aria-label="다음"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{/* 페이지 인디케이터 (콘텐츠 하단에 겹침) */}
|
|
{showIndicator && itemCount > 1 && (
|
|
<div className="absolute bottom-1 left-0 right-0 z-10 flex items-center justify-center gap-1.5">
|
|
{Array.from({ length: itemCount }).map((_, i) => (
|
|
<button
|
|
type="button"
|
|
key={i}
|
|
onClick={() => setCurrentIndex(i)}
|
|
className={`h-1.5 rounded-full transition-all ${
|
|
i === currentIndex
|
|
? "w-4 bg-primary"
|
|
: "w-1.5 bg-muted-foreground/30"
|
|
}`}
|
|
aria-label={`${i + 1}번째 아이템`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|