Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388"use client";
import { cva, type VariantProps } from "class-variance-authority";import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp,} from "lucide-react";import { Slot as SlotPrimitive } from "radix-ui";import * as React from "react";import { useComposedRefs } from "@/lib/compose-refs";import { cn } from "@/lib/utils";
const DATA_TOP_SCROLL = "data-top-scroll";const DATA_BOTTOM_SCROLL = "data-bottom-scroll";const DATA_LEFT_SCROLL = "data-left-scroll";const DATA_RIGHT_SCROLL = "data-right-scroll";const DATA_TOP_BOTTOM_SCROLL = "data-top-bottom-scroll";const DATA_LEFT_RIGHT_SCROLL = "data-left-right-scroll";
const scrollerVariants = cva("", { variants: { orientation: { vertical: [ "overflow-y-auto", "data-[top-scroll=true]:[mask-image:linear-gradient(0deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]", "data-[bottom-scroll=true]:[mask-image:linear-gradient(180deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]", "data-[top-bottom-scroll=true]:[mask-image:linear-gradient(#000,#000,transparent_0,#000_var(--scroll-shadow-size),#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]", ], horizontal: [ "overflow-x-auto", "data-[left-scroll=true]:[mask-image:linear-gradient(270deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]", "data-[right-scroll=true]:[mask-image:linear-gradient(90deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]", "data-[left-right-scroll=true]:[mask-image:linear-gradient(to_right,#000,#000,transparent_0,#000_var(--scroll-shadow-size),#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]", ], }, hideScrollbar: { true: "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", false: "", }, }, defaultVariants: { orientation: "vertical", hideScrollbar: false, },});
type ScrollDirection = "up" | "down" | "left" | "right";
type ScrollVisibility = { [key in ScrollDirection]: boolean;};
interface ScrollerProps extends VariantProps<typeof scrollerVariants>, React.ComponentProps<"div"> { size?: number; offset?: number; asChild?: boolean; withNavigation?: boolean; scrollStep?: number; scrollTriggerMode?: "press" | "hover" | "click";}
function Scroller(props: ScrollerProps) { const { orientation = "vertical", hideScrollbar, className, size = 40, offset = 0, scrollStep = 40, style, asChild, withNavigation = false, scrollTriggerMode = "press", ref, ...scrollerProps } = props;
const containerRef = React.useRef<HTMLDivElement | null>(null); const composedRef = useComposedRefs(ref, containerRef); const [scrollVisibility, setScrollVisibility] = React.useState<ScrollVisibility>({ up: false, down: false, left: false, right: false, });
const onScrollBy = React.useCallback( (direction: ScrollDirection) => { const container = containerRef.current; if (!container) return;
const scrollMap: Record<ScrollDirection, () => void> = { up: () => (container.scrollTop -= scrollStep), down: () => (container.scrollTop += scrollStep), left: () => (container.scrollLeft -= scrollStep), right: () => (container.scrollLeft += scrollStep), };
scrollMap[direction](); }, [scrollStep], );
const scrollHandlers = React.useMemo( () => ({ up: () => onScrollBy("up"), down: () => onScrollBy("down"), left: () => onScrollBy("left"), right: () => onScrollBy("right"), }), [onScrollBy], );
React.useLayoutEffect(() => { const container = containerRef.current; if (!container) return;
function onScroll() { if (!container) return;
const isVertical = orientation === "vertical";
if (isVertical) { const scrollTop = container.scrollTop; const clientHeight = container.clientHeight; const scrollHeight = container.scrollHeight;
if (withNavigation) { setScrollVisibility((prev) => { const newUp = scrollTop > offset; const newDown = scrollTop + clientHeight < scrollHeight;
if (prev.up !== newUp || prev.down !== newDown) { return { ...prev, up: newUp, down: newDown, }; } return prev; }); }
const hasTopScroll = scrollTop > offset; const hasBottomScroll = scrollTop + clientHeight + offset < scrollHeight; const isVerticallyScrollable = scrollHeight > clientHeight;
if (hasTopScroll && hasBottomScroll && isVerticallyScrollable) { container.setAttribute(DATA_TOP_BOTTOM_SCROLL, "true"); container.removeAttribute(DATA_TOP_SCROLL); container.removeAttribute(DATA_BOTTOM_SCROLL); } else { container.removeAttribute(DATA_TOP_BOTTOM_SCROLL); if (hasTopScroll) container.setAttribute(DATA_TOP_SCROLL, "true"); else container.removeAttribute(DATA_TOP_SCROLL); if (hasBottomScroll && isVerticallyScrollable) container.setAttribute(DATA_BOTTOM_SCROLL, "true"); else container.removeAttribute(DATA_BOTTOM_SCROLL); } }
const scrollLeft = container.scrollLeft; const clientWidth = container.clientWidth; const scrollWidth = container.scrollWidth;
if (withNavigation) { setScrollVisibility((prev) => { const newLeft = scrollLeft > offset; const newRight = scrollLeft + clientWidth < scrollWidth;
if (prev.left !== newLeft || prev.right !== newRight) { return { ...prev, left: newLeft, right: newRight, }; } return prev; }); }
const hasLeftScroll = scrollLeft > offset; const hasRightScroll = scrollLeft + clientWidth + offset < scrollWidth; const isHorizontallyScrollable = scrollWidth > clientWidth;
if (hasLeftScroll && hasRightScroll && isHorizontallyScrollable) { container.setAttribute(DATA_LEFT_RIGHT_SCROLL, "true"); container.removeAttribute(DATA_LEFT_SCROLL); container.removeAttribute(DATA_RIGHT_SCROLL); } else { container.removeAttribute(DATA_LEFT_RIGHT_SCROLL); if (hasLeftScroll) container.setAttribute(DATA_LEFT_SCROLL, "true"); else container.removeAttribute(DATA_LEFT_SCROLL); if (hasRightScroll && isHorizontallyScrollable) container.setAttribute(DATA_RIGHT_SCROLL, "true"); else container.removeAttribute(DATA_RIGHT_SCROLL); } }
onScroll(); container.addEventListener("scroll", onScroll); window.addEventListener("resize", onScroll);
return () => { container.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onScroll); }; }, [orientation, offset, withNavigation]);
const composedStyle = React.useMemo<React.CSSProperties>( () => ({ "--scroll-shadow-size": `${size}px`, ...style, }), [size, style], );
const activeDirections = React.useMemo<ScrollDirection[]>(() => { if (!withNavigation) return []; return orientation === "vertical" ? ["up", "down"] : ["left", "right"]; }, [orientation, withNavigation]);
const ScrollerPrimitive = asChild ? SlotPrimitive.Slot : "div";
const ScrollerImpl = ( <ScrollerPrimitive data-slot="scroller" {...scrollerProps} ref={composedRef} style={composedStyle} className={cn( scrollerVariants({ orientation, hideScrollbar, className }), )} /> );
const navigationButtons = React.useMemo(() => { if (!withNavigation) return null;
return activeDirections .filter((direction) => scrollVisibility[direction]) .map((direction) => ( <ScrollButton key={direction} data-slot="scroll-button" direction={direction} onClick={scrollHandlers[direction]} triggerMode={scrollTriggerMode} /> )); }, [ activeDirections, scrollVisibility, scrollHandlers, scrollTriggerMode, withNavigation, ]);
if (withNavigation) { return ( <div className="relative w-full"> {navigationButtons} {ScrollerImpl} </div> ); }
return ScrollerImpl;}
const scrollButtonVariants = cva( "absolute z-10 transition-opacity [&>svg]:size-4 [&>svg]:opacity-80 hover:[&>svg]:opacity-100", { variants: { direction: { up: "top-2 left-1/2 -translate-x-1/2", down: "bottom-2 left-1/2 -translate-x-1/2", left: "top-1/2 left-2 -translate-y-1/2", right: "top-1/2 right-2 -translate-y-1/2", }, }, defaultVariants: { direction: "up", }, },);
const directionToIcon: Record<ScrollDirection, React.ElementType> = { up: ChevronUp, down: ChevronDown, left: ChevronLeft, right: ChevronRight,} as const;
interface ScrollButtonProps extends React.ComponentProps<"button"> { direction: ScrollDirection; triggerMode?: "press" | "hover" | "click";}
function ScrollButton(props: ScrollButtonProps) { const { direction, className, triggerMode = "press", onClick, ref, ...buttonProps } = props;
const [autoScrollTimer, setAutoScrollTimer] = React.useState<number | null>( null, );
const onAutoScrollStart = React.useCallback( (event?: React.MouseEvent<HTMLButtonElement>) => { if (autoScrollTimer !== null) return;
if (triggerMode === "press") { const timer = window.setInterval(onClick ?? (() => {}), 50); setAutoScrollTimer(timer); } else if (triggerMode === "hover") { const timer = window.setInterval(() => { if (event) onClick?.(event); }, 50); setAutoScrollTimer(timer); } }, [autoScrollTimer, onClick, triggerMode], );
const onAutoScrollStop = React.useCallback(() => { if (autoScrollTimer === null) return;
window.clearInterval(autoScrollTimer); setAutoScrollTimer(null); }, [autoScrollTimer]);
const eventHandlers = React.useMemo(() => { const triggerModeHandlers: Record< NonNullable<ScrollerProps["scrollTriggerMode"]>, React.ComponentProps<"button"> > = { press: { onPointerDown: onAutoScrollStart, onPointerUp: onAutoScrollStop, onPointerLeave: onAutoScrollStop, onClick: () => {}, }, hover: { onPointerEnter: onAutoScrollStart, onPointerLeave: onAutoScrollStop, onClick: () => {}, }, click: { onClick, }, } as const;
return triggerModeHandlers[triggerMode] ?? {}; }, [triggerMode, onAutoScrollStart, onAutoScrollStop, onClick]);
React.useEffect(() => { return () => onAutoScrollStop(); }, [onAutoScrollStop]);
const Icon = directionToIcon[direction];
return ( <button type="button" {...buttonProps} {...eventHandlers} ref={ref} className={cn(scrollButtonVariants({ direction, className }))} > <Icon /> </button> );}
export { Scroller };