88 lines
3.4 KiB
TypeScript
88 lines
3.4 KiB
TypeScript
import { Children, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
|
|
import { Button } from "~/components/ui/button";
|
|
import { cn } from "~/lib/utils";
|
|
|
|
export function CategoryCardRail({
|
|
children,
|
|
className,
|
|
}: {
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
const items = Children.toArray(children);
|
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
const [rowHeight, setRowHeight] = useState<number>();
|
|
const [overflowing, setOverflowing] = useState(false);
|
|
const [atTop, setAtTop] = useState(true);
|
|
const [atBottom, setAtBottom] = useState(false);
|
|
|
|
const updateState = useCallback(() => {
|
|
const viewport = viewportRef.current;
|
|
if (!viewport) return;
|
|
const max = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
|
|
setOverflowing(max > 2);
|
|
setAtTop(viewport.scrollTop <= 2);
|
|
setAtBottom(viewport.scrollTop >= max - 2);
|
|
}, []);
|
|
|
|
const measure = useCallback(() => {
|
|
const first = trackRef.current?.querySelector<HTMLElement>("[data-rail-card]");
|
|
if (!first) return;
|
|
setRowHeight(Math.ceil(first.getBoundingClientRect().height + 2));
|
|
requestAnimationFrame(updateState);
|
|
}, [updateState]);
|
|
|
|
useEffect(() => {
|
|
measure();
|
|
if (typeof ResizeObserver === "undefined") return;
|
|
const observer = new ResizeObserver(measure);
|
|
if (trackRef.current) observer.observe(trackRef.current);
|
|
return () => observer.disconnect();
|
|
}, [items.length, measure]);
|
|
|
|
const scrollRow = (direction: -1 | 1) => {
|
|
const viewport = viewportRef.current;
|
|
const cards = trackRef.current?.querySelectorAll<HTMLElement>("[data-rail-card]");
|
|
if (!viewport || !cards?.length) return;
|
|
const absoluteRows = [...new Set([...cards].map((card) => Math.round(card.offsetTop)))].sort((a, b) => a - b);
|
|
const origin = absoluteRows[0] ?? 0;
|
|
const rows = absoluteRows.map((top) => top - origin);
|
|
const current = viewport.scrollTop;
|
|
const target = direction > 0
|
|
? rows.find((top) => top > current + 3) ?? rows.at(-1) ?? 0
|
|
: [...rows].reverse().find((top) => top < current - 3) ?? rows[0] ?? 0;
|
|
viewport.scrollTo({ top: target, behavior: "smooth" });
|
|
};
|
|
|
|
return (
|
|
<div className={cn("min-w-0", className)} data-slot="category-card-rail">
|
|
<div
|
|
className="scrollbar-thin overflow-x-hidden overflow-y-auto overscroll-contain pr-2 scroll-smooth"
|
|
ref={viewportRef}
|
|
style={rowHeight ? { height: rowHeight } : undefined}
|
|
onScroll={updateState}
|
|
>
|
|
<div className="grid auto-rows-fr grid-cols-2 gap-4 xl:grid-cols-4" ref={trackRef}>
|
|
{items.map((item, index) => (
|
|
<div className="min-w-0 [&>*]:h-full" data-rail-card key={index}>{item}</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{overflowing && (
|
|
<div className="mt-2 flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
|
<span className="mr-1">在卡片区域上下滚动查看更多模型大类</span>
|
|
<Button aria-label="上一行模型大类" disabled={atTop} size="icon-xs" variant="outline" onClick={() => scrollRow(-1)}>
|
|
<ChevronUp />
|
|
</Button>
|
|
<Button aria-label="下一行模型大类" disabled={atBottom} size="icon-xs" variant="outline" onClick={() => scrollRow(1)}>
|
|
<ChevronDown />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|