"use client" import { cva } from "class-variance-authority" import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cn } from "../../../lib/utils" interface ItemDimension { itemId: number size: number } type Side = "top" | "bottom" function getDataState(isExpanded: boolean) { return isExpanded ? "expanded" : "collapsed" } interface StackContextValue { side: Side childrenCount: number itemCount: number expandedItemCount: number gap: number scale: number offset: number expandOnHover: boolean isExpanded: boolean isInteracting: boolean dimensions: ItemDimension[] setDimensions: React.Dispatch> } const StackContext = React.createContext(null) function useStackContext(consumerName: string) { const context = React.useContext(StackContext) if (!context) { throw new Error(`\`${consumerName}\` must be used within \`Stack\``) } return context } interface StackProps extends React.ComponentProps<"div"> { side?: Side itemCount?: number expandedItemCount?: number gap?: number scale?: number offset?: number expandOnHover?: boolean asChild?: boolean } function Stack(props: StackProps) { const { side = "bottom", itemCount = 3, expandedItemCount, gap = 8, scale = 0.05, offset = 10, className, children, style, onMouseEnter: onMouseEnterProp, onMouseLeave: onMouseLeaveProp, onMouseMove: onMouseMoveProp, onPointerDown: onPointerDownProp, onPointerUp: onPointerUpProp, expandOnHover = false, asChild, ...rootProps } = props const [isExpanded, setIsExpanded] = React.useState(false) const [isInteracting, setIsInteracting] = React.useState(false) const [dimensions, setDimensions] = React.useState([]) const childrenArray = React.Children.toArray(children).filter( React.isValidElement, ) const childrenCount = childrenArray.length const effectiveExpandedItemCount = expandedItemCount ?? childrenCount const onMouseEnter = React.useCallback( (event: React.MouseEvent) => { onMouseEnterProp?.(event) if (event.defaultPrevented) return if (expandOnHover) { setIsExpanded(true) } }, [expandOnHover, onMouseEnterProp], ) const onMouseMove = React.useCallback( (event: React.MouseEvent) => { onMouseMoveProp?.(event) if (event.defaultPrevented) return if (expandOnHover) { setIsExpanded(true) } }, [expandOnHover, onMouseMoveProp], ) const onMouseLeave = React.useCallback( (event: React.MouseEvent) => { onMouseLeaveProp?.(event) if (event.defaultPrevented) return if (expandOnHover && !isInteracting) { setIsExpanded(false) } }, [expandOnHover, isInteracting, onMouseLeaveProp], ) const onPointerDown = React.useCallback( (event: React.PointerEvent) => { onPointerDownProp?.(event) if (event.defaultPrevented) return setIsInteracting(true) }, [onPointerDownProp], ) const onPointerUp = React.useCallback( (event: React.PointerEvent) => { onPointerUpProp?.(event) if (event.defaultPrevented) return setIsInteracting(false) }, [onPointerUpProp], ) const contextValue = React.useMemo( () => ({ side, childrenCount, itemCount, expandedItemCount: effectiveExpandedItemCount, gap, scale, offset, expandOnHover, isExpanded, isInteracting, dimensions, setDimensions, }), [ side, childrenCount, itemCount, effectiveExpandedItemCount, gap, scale, offset, expandOnHover, isExpanded, isInteracting, dimensions, ], ) const RootPrimitive = asChild ? Slot : "div" return ( {childrenArray.map((child, index) => ( {child} ))} ) } const stackItemWrapperVariants = cva( "absolute w-full transition-all duration-300 ease-out", { variants: { side: { top: [ "top-0 left-0 origin-top", "translate-y-[calc(var(--translate)*-1)] scale-[var(--item-scale)]", "after:absolute after:top-full after:left-0 after:w-full after:content-['']", ], bottom: [ "bottom-0 left-0 origin-bottom", "translate-y-[var(--translate)] scale-[var(--item-scale)]", "after:absolute after:bottom-full after:left-0 after:w-full after:content-['']", ], }, isExpanded: { true: "after:h-[calc(var(--gap)+1px)]", false: "", }, isVisible: { true: "", false: "pointer-events-none", }, }, }, ) type StackItemWrapperElement = HTMLDivElement interface StackItemWrapperProps extends React.ComponentProps<"div"> { index: number } function StackItemWrapper(props: StackItemWrapperProps) { const { children, className, index, style, ...itemProps } = props const { side, childrenCount, itemCount, expandedItemCount, gap, scale, offset, isExpanded, dimensions, setDimensions, } = useStackContext("StackItemWrapper") const itemRef = React.useRef(null) const isFront = index === 0 const isVisible = isExpanded ? index < expandedItemCount : index < itemCount React.useEffect(() => { const itemNode = itemRef.current if (itemNode) { const rect = itemNode.getBoundingClientRect() const measuredHeight = rect.height const currentScale = 1 - index * scale const naturalHeight = measuredHeight / currentScale setDimensions((d) => { const existing = d.find((item) => item.itemId === index) if (!existing) { return [...d, { itemId: index, size: naturalHeight }] } return d }) } }, [index, scale, setDimensions]) const itemsSizeBefore = React.useMemo(() => { return dimensions.reduce((prev, curr) => { if (curr.itemId >= index) return prev return prev + curr.size }, 0) }, [dimensions, index]) const itemScale = isExpanded ? 1 : 1 - index * scale const translateValue = isExpanded ? index * gap + itemsSizeBefore : index * offset const zIndex = childrenCount - index const opacity = !isVisible ? 0 : isExpanded ? 1 : 1 - index * 0.15 return (
{children}
) } interface StackItemProps extends React.ComponentProps<"div"> { asChild?: boolean } function StackItem(props: StackItemProps) { const { asChild, className, ...itemProps } = props const ItemPrimitive = asChild ? Slot : "div" return ( ) } export { Stack, StackItem, type StackProps }