import { useState, useRef, useLayoutEffect, type KeyboardEvent, type ReactNode } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { cn } from '@/lib/utils' import { MOTION_EXPAND } from '@/lib/motion' import { ArrowDownSLine } from './icons-inline' export interface CollapsibleCardProps { headerIcon?: ReactNode headerTitle: ReactNode headerRight?: ReactNode children?: ReactNode defaultExpanded?: boolean /** CSS padding 如 '6px 8px',或 Tailwind 类 */ contentPadding?: string onToggle?: (expanded: boolean) => void collapsible?: boolean footer?: ReactNode contentMaxHeight?: number /** 是否启用内容区底部展开/收起栏:内容高度超过 maxHeight 时显示,点击切换全部展开或收起 */ showExpandAllBar?: boolean /** showExpandAllBar 为 true 时,未展开全部时的内容区最大高度(px) */ maxHeight?: number titleShimmer?: boolean className?: string } const DEFAULT_MAX_HEIGHT = 124 export function CollapsibleCard({ headerIcon, headerTitle, headerRight, children, defaultExpanded = true, contentPadding = 'var(--spacing-1.5) var(--spacing-2)', onToggle, collapsible = true, footer, contentMaxHeight, showExpandAllBar = false, maxHeight = DEFAULT_MAX_HEIGHT, titleShimmer = false, className, }: CollapsibleCardProps) { const [isExpanded, setIsExpanded] = useState(defaultExpanded) const [isHovered, setIsHovered] = useState(false) const [expandAll, setExpandAll] = useState(false) const [shouldShowBar, setShouldShowBar] = useState(false) const [contentHeight, setContentHeight] = useState(0) const [isMeasuring, setIsMeasuring] = useState(showExpandAllBar && defaultExpanded) const [isAnimating, setIsAnimating] = useState(false) const measureRef = useRef(null) useLayoutEffect(() => { if (!showExpandAllBar || !isExpanded) { setShouldShowBar(false) setContentHeight(0) setIsMeasuring(false) return } setIsMeasuring(true) const checkHeight = () => { const el = measureRef.current if (!el) return const actualHeight = el.scrollHeight setContentHeight(actualHeight) setShouldShowBar(actualHeight > maxHeight) setIsMeasuring(false) } const raf = requestAnimationFrame(checkHeight) let resizeObserver: ResizeObserver | null = null if (measureRef.current) { resizeObserver = new ResizeObserver(checkHeight) resizeObserver.observe(measureRef.current) } return () => { cancelAnimationFrame(raf) resizeObserver?.disconnect() } }, [showExpandAllBar, isExpanded, children, maxHeight]) const handleToggle = () => { if (!collapsible) return const next = !isExpanded setIsAnimating(true) if (next && showExpandAllBar) setIsMeasuring(true) setIsExpanded(next) onToggle?.(next) setTimeout(() => setIsAnimating(false), 300) } const handleToggleKeyDown = (event: KeyboardEvent) => { if (!collapsible) return if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() handleToggle() } const showArrow = collapsible && (isHovered || headerIcon == null) const headerIconNode = showArrow ? : headerIcon const useExpandAllLogic = showExpandAllBar && isExpanded && !contentMaxHeight const contentClamped = useExpandAllLogic && (isMeasuring || (shouldShowBar && !expandAll) || isAnimating || (!shouldShowBar && contentHeight === 0)) const contentMaxHeightStyle = contentMaxHeight != null ? `${contentMaxHeight}px` : contentClamped ? `${maxHeight}px` : useExpandAllLogic && shouldShowBar && expandAll && contentHeight > 0 ? `${Math.max(contentHeight + 50, 2000)}px` : undefined return (
collapsible && setIsHovered(true)} onMouseLeave={() => collapsible && setIsHovered(false)} >
{headerIconNode}
{titleShimmer ? ( {headerTitle} ) : ( headerTitle )}
{headerRight != null && (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > {headerRight}
)}
{isExpanded && ( {/* 隐藏测量用,用于获取内容实际高度 */} {useExpandAllLogic && (
{children}
)}
{children} {/* 底部渐变遮罩:内容被截断且未展开全部时显示 */} {useExpandAllLogic && shouldShowBar && contentHeight > maxHeight && (
)}
{/* 内容区底部展开/收起栏:仅当内容高度超过 maxHeight 时显示 */} {useExpandAllLogic && shouldShowBar && ( )} )} {footer != null && (
{footer}
)}
) } CollapsibleCard.displayName = 'CollapsibleCard'