수정
This commit is contained in:
153
src/app/components/HorizontalCardScroller.tsx
Normal file
153
src/app/components/HorizontalCardScroller.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type CardItem = {
|
||||
id: number;
|
||||
region: string;
|
||||
name: string;
|
||||
address: string;
|
||||
image: string;
|
||||
};
|
||||
|
||||
interface HorizontalCardScrollerProps {
|
||||
items: CardItem[];
|
||||
}
|
||||
|
||||
export default function HorizontalCardScroller({ items }: HorizontalCardScrollerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const [thumbWidth, setThumbWidth] = useState<number>(60);
|
||||
const [thumbLeft, setThumbLeft] = useState<number>(0);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const dragOffsetRef = useRef<number>(0);
|
||||
|
||||
const CARD_WIDTH = 384;
|
||||
const CARD_GAP = 16; // Tailwind gap-4
|
||||
const SCROLL_STEP = CARD_WIDTH + CARD_GAP;
|
||||
|
||||
const updateThumb = useCallback(() => {
|
||||
const scroller = scrollRef.current;
|
||||
const track = trackRef.current;
|
||||
if (!scroller || !track) return;
|
||||
|
||||
const { scrollWidth, clientWidth, scrollLeft } = scroller;
|
||||
const trackWidth = track.clientWidth;
|
||||
if (scrollWidth <= 0 || trackWidth <= 0) return;
|
||||
|
||||
const ratioVisible = Math.max(0, Math.min(1, clientWidth / scrollWidth));
|
||||
const newThumbWidth = Math.max(40, Math.round(trackWidth * ratioVisible));
|
||||
const maxThumbLeft = Math.max(0, trackWidth - newThumbWidth);
|
||||
const ratioPosition = scrollWidth === clientWidth ? 0 : scrollLeft / (scrollWidth - clientWidth);
|
||||
const newThumbLeft = Math.round(maxThumbLeft * ratioPosition);
|
||||
|
||||
setThumbWidth(newThumbWidth);
|
||||
setThumbLeft(newThumbLeft);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateThumb();
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => updateThumb();
|
||||
const onResize = () => updateThumb();
|
||||
el.addEventListener("scroll", onScroll);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [updateThumb]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const el = scrollRef.current;
|
||||
const track = trackRef.current;
|
||||
if (!el || !track) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
let x = e.clientX - rect.left - dragOffsetRef.current;
|
||||
x = Math.max(0, Math.min(x, rect.width - thumbWidth));
|
||||
setThumbLeft(x);
|
||||
const ratio = rect.width === thumbWidth ? 0 : x / (rect.width - thumbWidth);
|
||||
const targetScrollLeft = ratio * (el.scrollWidth - el.clientWidth);
|
||||
el.scrollLeft = targetScrollLeft;
|
||||
};
|
||||
const onUp = () => setIsDragging(false);
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [isDragging, thumbWidth]);
|
||||
|
||||
const handleThumbMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = trackRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
dragOffsetRef.current = e.clientX - rect.left - thumbLeft;
|
||||
setIsDragging(true);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const scrollByStep = (direction: 1 | -1) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: direction * SCROLL_STEP, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-[400px]">
|
||||
<div ref={scrollRef} className="scrollbar-hidden h-full overflow-x-auto overflow-y-hidden">
|
||||
<div className="flex h-full items-center gap-4">
|
||||
{items.map((card) => (
|
||||
<article key={card.id} className="flex-shrink-0 w-[384px] h-[324px] rounded-xl bg-white overflow-hidden shadow-sm p-2">
|
||||
<div className="grid grid-rows-[192px_116px] h-full">
|
||||
<div className="w-full h-[192px] overflow-hidden rounded-lg">
|
||||
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="h-[116px] flex flex-col justify-center px-2">
|
||||
<p className="text-sm text-neutral-600">{card.region}</p>
|
||||
<p className="text-base font-semibold">{card.name}</p>
|
||||
<p className="text-sm text-neutral-600">{card.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-[20px] left-1/2 -translate-x-1/2 z-20 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="이전"
|
||||
className="pointer-events-auto p-0 m-0 bg-transparent text-orange-500 hover:text-orange-600 focus:outline-none"
|
||||
onClick={() => scrollByStep(-1)}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
|
||||
<polygon points="10,0 0,6 10,12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div ref={trackRef} className="pointer-events-auto relative h-2 w-[30vw] rounded-full bg-orange-200/50">
|
||||
<div
|
||||
className="absolute top-0 h-2 rounded-full bg-orange-500"
|
||||
style={{ width: `${thumbWidth}px`, left: `${thumbLeft}px` }}
|
||||
onMouseDown={handleThumbMouseDown}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="다음"
|
||||
className="pointer-events-auto p-0 m-0 bg-transparent text-orange-500 hover:text-orange-600 focus:outline-none"
|
||||
onClick={() => scrollByStep(1)}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
|
||||
<polygon points="0,0 10,6 0,12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user