返回 AiToEarn
tooltip.tsx
根目录 / project / aitoearn-web / src / components / ui / tooltip.tsx
1 /**
2 * Tooltip 组件
3 *
4 * 功能描述: 提示气泡组件,PC 端 hover 触发,移动端点击触发
5 * 基于 Radix UI Tooltip 封装,自动适配移动端交互
6 */
7 'use client'
8
9 import * as TooltipPrimitive from '@radix-ui/react-tooltip'
10 import * as React from 'react'
11 import { useCallback, useEffect, useRef, useState } from 'react'
12
13 import { useIsMobile } from '@/hooks/useIsMobile'
14 import { cn } from '@/utils/className'
15
16 const TooltipProvider = TooltipPrimitive.Provider
17
18 // PC 端原始 Tooltip(hover 触发)
19 const TooltipDesktop = TooltipPrimitive.Root
20 const TooltipTrigger = TooltipPrimitive.Trigger
21
22 /**
23 * 移动端 Tooltip:点击触发,点击外部关闭
24 */
25 function TooltipMobile({
26 children,
27 open: controlledOpen,
28 onOpenChange: controlledOnOpenChange,
29 defaultOpen,
30 ...props
31 }: TooltipPrimitive.TooltipProps) {
32 const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false)
33 const isControlled = controlledOpen !== undefined
34 const open = isControlled ? controlledOpen : uncontrolledOpen
35 const onOpenChange = isControlled ? controlledOnOpenChange : setUncontrolledOpen
36
37 const containerRef = useRef<HTMLDivElement>(null)
38 const contentRef = useRef<HTMLDivElement>(null)
39
40 const handleToggle = useCallback(
41 (e: React.MouseEvent | React.TouchEvent) => {
42 e.preventDefault()
43 e.stopPropagation()
44 onOpenChange?.(!open)
45 },
46 [open, onOpenChange],
47 )
48
49 // 点击外部关闭(排除 Trigger 和 Content 区域)
50 useEffect(() => {
51 if (!open)
52 return
53 const handleClickOutside = (e: MouseEvent | TouchEvent) => {
54 const target = e.target as Node
55 if (
56 containerRef.current && !containerRef.current.contains(target)
57 && (!contentRef.current || !contentRef.current.contains(target))
58 ) {
59 onOpenChange?.(false)
60 }
61 }
62 document.addEventListener('pointerdown', handleClickOutside)
63 return () => {
64 document.removeEventListener('pointerdown', handleClickOutside)
65 }
66 }, [open, onOpenChange])
67
68 return (
69 <TooltipPrimitive.Root {...props} open={open} onOpenChange={onOpenChange}>
70 {React.Children.map(children, (child) => {
71 if (!React.isValidElement(child))
72 return child
73 // 给 Trigger 注入点击事件
74 if (child.type === TooltipTrigger) {
75 return (
76 <div ref={containerRef} className="inline-flex" onClick={handleToggle}>
77 {React.cloneElement(child as React.ReactElement<any>, {
78 // 阻止 Radix 默认的 pointer 行为
79 onPointerDown: (e: React.PointerEvent) => e.preventDefault(),
80 })}
81 </div>
82 )
83 }
84 // 给 Content 注入 ref,用于点击外部关闭时排除内容区域
85 if (child.type === TooltipContent) {
86 return React.cloneElement(child as React.ReactElement<any>, {
87 ref: contentRef,
88 })
89 }
90 return child
91 })}
92 </TooltipPrimitive.Root>
93 )
94 }
95
96 /**
97 * 自适应 Tooltip:自动根据设备类型选择交互方式
98 * - PC 端:hover 触发(Radix 默认行为)
99 * - 移动端:点击触发,点击外部关闭
100 */
101 function Tooltip(props: TooltipPrimitive.TooltipProps) {
102 const isMobile = useIsMobile()
103
104 if (isMobile) {
105 return <TooltipMobile {...props} />
106 }
107
108 return <TooltipDesktop {...props} />
109 }
110
111 const TooltipContent = React.forwardRef<
112 React.ElementRef<typeof TooltipPrimitive.Content>,
113 React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
114 >(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => (
115 <TooltipPrimitive.Portal>
116 <TooltipPrimitive.Content
117 ref={ref}
118 sideOffset={sideOffset}
119 collisionPadding={collisionPadding}
120 className={cn(
121 'z-50 max-w-[calc(100vw-1rem)] break-words overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin] sm:max-w-80',
122 className,
123 )}
124 {...props}
125 />
126 </TooltipPrimitive.Portal>
127 ))
128 TooltipContent.displayName = TooltipPrimitive.Content.displayName
129
130 export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
131
131 lines Plain Text