Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT-4] 전형 상세 선택지 가로 드래깅 구현 #18

Merged
merged 8 commits into from
Feb 4, 2025
2 changes: 1 addition & 1 deletion src/components/layout/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-dvh items-center justify-center bg-[#EEEE]">
<div className="mobile:h-full mobile:min-h-[480px] mobile:min-w-[320px] mobile:rounded-none desktop:h-[780px] desktop:max-w-[390px] desktop:rounded-3xl desktop:border desktop:border-gray-200 desktop:shadow-2xl relative flex h-full w-full flex-col overflow-hidden border bg-[#E7E7E7]">
<div className="relative flex h-full w-full flex-col overflow-hidden border bg-[#E7E7E7] mobile:h-full mobile:min-h-[480px] mobile:min-w-[320px] mobile:rounded-none desktop:h-[780px] desktop:max-w-[390px] desktop:rounded-3xl desktop:border desktop:border-gray-200 desktop:shadow-2xl">
{children}
</div>
</div>
Expand Down
102 changes: 102 additions & 0 deletions src/hooks/use-draggable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { RefObject } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import throttle from '@/utils/throttle';

export function useDraggable(scrollRef: RefObject<HTMLDivElement | null>) {
const [isDragging, setIsDragging] = useState<boolean>(false);

const [totalX, setTotalX] = useState(0);
const velocityRef = useRef<number>(0);
const lastMouseXRef = useRef<number>(0);
const lastTimestampRef = useRef<number>(0);
const animationFrameRef = useRef<number>();

const onDragStart = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
const x = e.clientX;
lastMouseXRef.current = x;
lastTimestampRef.current = Date.now();
velocityRef.current = 0;

if (scrollRef.current && 'scrollLeft' in scrollRef.current) {
setTotalX(x + scrollRef.current.scrollLeft);
}

if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};

const onDragMove = throttle((e: React.MouseEvent) => {
if (!isDragging) return;
e.preventDefault();
e.stopPropagation();

const currentX = e.clientX;
const currentTimestamp = Date.now();
const deltaTime = currentTimestamp - lastTimestampRef.current;

if (deltaTime > 0) {
const deltaX = currentX - lastMouseXRef.current;
velocityRef.current = deltaX / deltaTime;
}

lastMouseXRef.current = currentX;
lastTimestampRef.current = currentTimestamp;

const scrollLeft = totalX - currentX;
if (scrollRef.current && 'scrollLeft' in scrollRef.current) {
scrollRef.current.scrollLeft = scrollLeft;
}
}, 10);

const applyMomentum = useCallback(() => {
if (!scrollRef.current) return;

const deceleration = 0.95;
const stopThreshold = 0.01;

const animate = () => {
if (!scrollRef.current) return;

velocityRef.current *= deceleration;

scrollRef.current.scrollLeft -= velocityRef.current * 16;

if (Math.abs(velocityRef.current) > stopThreshold) {
animationFrameRef.current = requestAnimationFrame(animate);
}
};

animate();
}, []);

const onDragEnd = (e: React.MouseEvent) => {
if (!isDragging) return;
if (!scrollRef.current) return;
e.preventDefault();
e.stopPropagation();
setIsDragging(false);

if (Math.abs(velocityRef.current) > 0.1) {
applyMomentum();
}
};

useEffect(() => {
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, []);

return {
onMouseDown: onDragStart,
onMouseMove: onDragMove,
onMouseUp: onDragEnd,
onMouseLeave: onDragEnd,
};
}
13 changes: 13 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,16 @@
.bg-image-tree-right {
@apply bg-[url('./assets/svg/tree.svg')] bg-contain bg-right bg-no-repeat;
}

@layer utilities {
.scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}

.scrollbar::-webkit-scrollbar-thumb {
background: rgb(209, 209, 209);
border: 1px solid rgb(209, 209, 209); /* 배경색과 같은 색상의 보더 */
border-radius: 100px;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import TextMenu from '@/components/menu-items/text-menu/text-menu';
import MenuList from '@/components/menu-list/menu-list';
import systemMessage from '@/constants/message';
import useAdmissionDetail from '@/hooks/querys/useAdmissionDetail';
import DraggableScroller from '@/page/components/chat-step/choose-admission-category/draggable-scroller';
import useAdmissionStore from '@/stores/store/admission-store';
import useMessagesStore from '@/stores/store/message-store';
import { ADDMISSION, AdmissionType } from '@/types/admission-type';
Expand All @@ -18,6 +19,7 @@ function ChooseAdmissionCategory({ admissionType, changeStep }: Props) {
const { setMessages } = useMessagesStore();
const { setAdmissionCategory } = useAdmissionStore();
const data = useAdmissionDetail(admissionType);

const selectCategory = (category: string) => {
changeStep('상세전형 선택 결과');
setMessages([
Expand Down Expand Up @@ -47,7 +49,7 @@ function ChooseAdmissionCategory({ admissionType, changeStep }: Props) {
return (
<div>
<div className="mt-2">
<div className="flex w-80 cursor-grab flex-nowrap items-start gap-5 overflow-x-auto">
<DraggableScroller>
{data.map((option) => (
<MenuList key={option.label}>
<MenuList.Title title={`${option.label}전형`} />
Expand All @@ -56,7 +58,7 @@ function ChooseAdmissionCategory({ admissionType, changeStep }: Props) {
))}
</MenuList>
))}
</div>
</DraggableScroller>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useRef } from 'react';
import { useDraggable } from '@/hooks/use-draggable';

function DraggableScroller({ children }: { children: React.ReactNode }) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const events = useDraggable(scrollRef);

return (
<div
ref={scrollRef}
className="scrollbar flex w-80 cursor-grab select-none flex-nowrap items-start gap-5 overflow-x-auto"
{...events}
>
{children}
</div>
);
}

export default DraggableScroller;
2 changes: 1 addition & 1 deletion src/page/components/main/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function Main() {

return (
<>
<main className="flex h-full flex-col gap-6 overflow-y-auto overflow-x-hidden">
<main className="scrollbar flex h-full flex-col gap-6 overflow-y-auto overflow-x-hidden">
<div className="flex-1 py-6 pl-12 pr-3">
<MessageHistory />
<Funnel step={steps}>
Expand Down
15 changes: 15 additions & 0 deletions src/utils/throttle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function throttle<T extends (...args: any[]) => void>(callback: T, delay: number) {
let timerId: ReturnType<typeof setTimeout> | null = null;

return (...args: Parameters<T>) => {
if (timerId) return;

timerId = setTimeout(() => {
callback(...args);
timerId = null;
}, delay);
};
}

export default throttle;