119 lines
3.2 KiB
TypeScript
119 lines
3.2 KiB
TypeScript
"use client";
|
|
|
|
import type { ComponentProps } from "react";
|
|
import { useCallback, useEffect, useRef } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/shared/ui/button";
|
|
|
|
export type SuggestionsProps = ComponentProps<"div">;
|
|
|
|
const SCROLL_EASING = 0.28;
|
|
const SCROLL_END_THRESHOLD = 0.5;
|
|
|
|
export const Suggestions = ({ className, children, ...props }: SuggestionsProps) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const element = containerRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
let animationFrameId: number | null = null;
|
|
let targetScrollLeft = element.scrollLeft;
|
|
|
|
const animateScroll = () => {
|
|
const distance = targetScrollLeft - element.scrollLeft;
|
|
if (Math.abs(distance) <= SCROLL_END_THRESHOLD) {
|
|
element.scrollLeft = targetScrollLeft;
|
|
animationFrameId = null;
|
|
return;
|
|
}
|
|
|
|
element.scrollLeft += distance * SCROLL_EASING;
|
|
animationFrameId = window.requestAnimationFrame(animateScroll);
|
|
};
|
|
|
|
const handleWheel = (event: globalThis.WheelEvent) => {
|
|
const maxScrollLeft = element.scrollWidth - element.clientWidth;
|
|
const rawDelta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX;
|
|
const delta =
|
|
event.deltaMode === 1
|
|
? rawDelta * 16
|
|
: event.deltaMode === 2
|
|
? rawDelta * element.clientWidth
|
|
: rawDelta;
|
|
const canScroll = delta > 0 ? element.scrollLeft < maxScrollLeft : element.scrollLeft > 0;
|
|
|
|
if (maxScrollLeft <= 0 || delta === 0 || !canScroll) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
targetScrollLeft = Math.min(maxScrollLeft, Math.max(0, targetScrollLeft + delta));
|
|
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
element.scrollLeft = targetScrollLeft;
|
|
return;
|
|
}
|
|
|
|
if (animationFrameId === null) {
|
|
animationFrameId = window.requestAnimationFrame(animateScroll);
|
|
}
|
|
};
|
|
|
|
element.addEventListener("wheel", handleWheel, { passive: false });
|
|
return () => {
|
|
element.removeEventListener("wheel", handleWheel);
|
|
if (animationFrameId !== null) {
|
|
window.cancelAnimationFrame(animationFrameId);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
"flex w-full flex-nowrap items-center gap-2 overflow-x-auto overscroll-x-contain pb-0.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
|
|
suggestion: string;
|
|
onClick?: (suggestion: string) => void;
|
|
};
|
|
|
|
export const Suggestion = ({
|
|
suggestion,
|
|
onClick,
|
|
className,
|
|
variant = "outline",
|
|
size = "sm",
|
|
children,
|
|
...props
|
|
}: SuggestionProps) => {
|
|
const handleClick = useCallback(() => {
|
|
onClick?.(suggestion);
|
|
}, [onClick, suggestion]);
|
|
|
|
return (
|
|
<Button
|
|
className={cn("shrink-0 cursor-pointer rounded-full px-3.5", className)}
|
|
onClick={handleClick}
|
|
size={size}
|
|
type="button"
|
|
variant={variant}
|
|
{...props}
|
|
>
|
|
{children || suggestion}
|
|
</Button>
|
|
);
|
|
};
|