Files
model-platform/frontend/app/features/operations/CategoryCardRail.tsx
T

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>
);
}