fix(agent): validate UI envelopes
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user