18 lines
14 KiB
JSON
18 lines
14 KiB
JSON
{
|
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
"name": "tooltip",
|
|
"type": "registry:ui",
|
|
"title": "Tooltip",
|
|
"description": "An animated tooltip that shows contextual info on hover or focus and smoothly glides to the next element without disappearing between transitions.",
|
|
"dependencies": [
|
|
"motion"
|
|
],
|
|
"files": [
|
|
{
|
|
"path": "registry/components/tooltip/index.tsx",
|
|
"content": "'use client';\n\nimport * as React from 'react';\nimport { createPortal } from 'react-dom';\nimport {\n motion,\n AnimatePresence,\n LayoutGroup,\n type Transition,\n} from 'motion/react';\n\nimport { cn } from '@/lib/utils';\n\ntype Side = 'top' | 'bottom' | 'left' | 'right';\n\ntype Align = 'start' | 'center' | 'end';\n\ntype TooltipData = {\n content: React.ReactNode;\n rect: DOMRect;\n side: Side;\n sideOffset: number;\n align: Align;\n alignOffset: number;\n id: string;\n arrow: boolean;\n};\n\ntype GlobalTooltipContextType = {\n showTooltip: (data: TooltipData) => void;\n hideTooltip: () => void;\n currentTooltip: TooltipData | null;\n transition: Transition;\n globalId: string;\n};\n\nconst GlobalTooltipContext = React.createContext<\n GlobalTooltipContextType | undefined\n>(undefined);\n\nconst useGlobalTooltip = () => {\n const context = React.useContext(GlobalTooltipContext);\n if (!context) {\n throw new Error('useGlobalTooltip must be used within a TooltipProvider');\n }\n return context;\n};\n\ntype TooltipPosition = {\n x: number;\n y: number;\n transform: string;\n initial: { x?: number; y?: number };\n};\n\nfunction getTooltipPosition({\n rect,\n side,\n sideOffset,\n align,\n alignOffset,\n}: {\n rect: DOMRect;\n side: Side;\n sideOffset: number;\n align: Align;\n alignOffset: number;\n}): TooltipPosition {\n switch (side) {\n case 'top':\n if (align === 'start') {\n return {\n x: rect.left + alignOffset,\n y: rect.top - sideOffset,\n transform: 'translate(0, -100%)',\n initial: { y: 15 },\n };\n } else if (align === 'end') {\n return {\n x: rect.right + alignOffset,\n y: rect.top - sideOffset,\n transform: 'translate(-100%, -100%)',\n initial: { y: 15 },\n };\n } else {\n // center\n return {\n x: rect.left + rect.width / 2,\n y: rect.top - sideOffset,\n transform: 'translate(-50%, -100%)',\n initial: { y: 15 },\n };\n }\n case 'bottom':\n if (align === 'start') {\n return {\n x: rect.left + alignOffset,\n y: rect.bottom + sideOffset,\n transform: 'translate(0, 0)',\n initial: { y: -15 },\n };\n } else if (align === 'end') {\n return {\n x: rect.right + alignOffset,\n y: rect.bottom + sideOffset,\n transform: 'translate(-100%, 0)',\n initial: { y: -15 },\n };\n } else {\n // center\n return {\n x: rect.left + rect.width / 2,\n y: rect.bottom + sideOffset,\n transform: 'translate(-50%, 0)',\n initial: { y: -15 },\n };\n }\n case 'left':\n if (align === 'start') {\n return {\n x: rect.left - sideOffset,\n y: rect.top + alignOffset,\n transform: 'translate(-100%, 0)',\n initial: { x: 15 },\n };\n } else if (align === 'end') {\n return {\n x: rect.left - sideOffset,\n y: rect.bottom + alignOffset,\n transform: 'translate(-100%, -100%)',\n initial: { x: 15 },\n };\n } else {\n // center\n return {\n x: rect.left - sideOffset,\n y: rect.top + rect.height / 2,\n transform: 'translate(-100%, -50%)',\n initial: { x: 15 },\n };\n }\n case 'right':\n if (align === 'start') {\n return {\n x: rect.right + sideOffset,\n y: rect.top + alignOffset,\n transform: 'translate(0, 0)',\n initial: { x: -15 },\n };\n } else if (align === 'end') {\n return {\n x: rect.right + sideOffset,\n y: rect.bottom + alignOffset,\n transform: 'translate(0, -100%)',\n initial: { x: -15 },\n };\n } else {\n // center\n return {\n x: rect.right + sideOffset,\n y: rect.top + rect.height / 2,\n transform: 'translate(0, -50%)',\n initial: { x: -15 },\n };\n }\n }\n}\n\ntype TooltipProviderProps = {\n children: React.ReactNode;\n openDelay?: number;\n closeDelay?: number;\n transition?: Transition;\n};\n\nfunction TooltipProvider({\n children,\n openDelay = 700,\n closeDelay = 300,\n transition = { type: 'spring', stiffness: 300, damping: 25 },\n}: TooltipProviderProps) {\n const globalId = React.useId();\n const [currentTooltip, setCurrentTooltip] =\n React.useState<TooltipData | null>(null);\n const timeoutRef = React.useRef<number>(null);\n const lastCloseTimeRef = React.useRef<number>(0);\n\n const showTooltip = React.useCallback(\n (data: TooltipData) => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current);\n if (currentTooltip !== null) {\n setCurrentTooltip(data);\n return;\n }\n const now = Date.now();\n const delay = now - lastCloseTimeRef.current < closeDelay ? 0 : openDelay;\n timeoutRef.current = window.setTimeout(\n () => setCurrentTooltip(data),\n delay,\n );\n },\n [openDelay, closeDelay, currentTooltip],\n );\n\n const hideTooltip = React.useCallback(() => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current);\n timeoutRef.current = window.setTimeout(() => {\n setCurrentTooltip(null);\n lastCloseTimeRef.current = Date.now();\n }, closeDelay);\n }, [closeDelay]);\n\n const hideImmediate = React.useCallback(() => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current);\n setCurrentTooltip(null);\n lastCloseTimeRef.current = Date.now();\n }, []);\n\n React.useEffect(() => {\n window.addEventListener('scroll', hideImmediate, true);\n return () => window.removeEventListener('scroll', hideImmediate, true);\n }, [hideImmediate]);\n\n return (\n <GlobalTooltipContext.Provider\n value={{\n showTooltip,\n hideTooltip,\n currentTooltip,\n transition,\n globalId,\n }}\n >\n <LayoutGroup>{children}</LayoutGroup>\n <TooltipOverlay />\n </GlobalTooltipContext.Provider>\n );\n}\n\ntype TooltipArrowProps = {\n side: Side;\n};\n\nfunction TooltipArrow({ side }: TooltipArrowProps) {\n return (\n <div\n className={cn(\n 'absolute bg-primary z-50 size-2.5 rotate-45 rounded-[2px]',\n (side === 'top' || side === 'bottom') && 'left-1/2 -translate-x-1/2',\n (side === 'left' || side === 'right') && 'top-1/2 -translate-y-1/2',\n side === 'top' && '-bottom-[3px]',\n side === 'bottom' && '-top-[3px]',\n side === 'left' && '-right-[3px]',\n side === 'right' && '-left-[3px]',\n )}\n />\n );\n}\n\ntype TooltipPortalProps = {\n children: React.ReactNode;\n};\n\nfunction TooltipPortal({ children }: TooltipPortalProps) {\n const [isMounted, setIsMounted] = React.useState(false);\n React.useEffect(() => setIsMounted(true), []);\n return isMounted ? createPortal(children, document.body) : null;\n}\n\nfunction TooltipOverlay() {\n const { currentTooltip, transition, globalId } = useGlobalTooltip();\n\n const position = React.useMemo(() => {\n if (!currentTooltip) return null;\n return getTooltipPosition({\n rect: currentTooltip.rect,\n side: currentTooltip.side,\n sideOffset: currentTooltip.sideOffset,\n align: currentTooltip.align,\n alignOffset: currentTooltip.alignOffset,\n });\n }, [currentTooltip]);\n\n return (\n <AnimatePresence>\n {currentTooltip && currentTooltip.content && position && (\n <TooltipPortal>\n <motion.div\n data-slot=\"tooltip-overlay-container\"\n className=\"fixed z-50\"\n style={{\n top: position.y,\n left: position.x,\n transform: position.transform,\n }}\n >\n <motion.div\n data-slot=\"tooltip-overlay\"\n layoutId={`tooltip-overlay-${globalId}`}\n initial={{ opacity: 0, scale: 0, ...position.initial }}\n animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}\n exit={{ opacity: 0, scale: 0, ...position.initial }}\n transition={transition}\n className=\"relative rounded-md bg-primary fill-primary px-3 py-1.5 text-sm text-primary-foreground shadow-md w-fit text-balance\"\n >\n {currentTooltip.content}\n\n {currentTooltip.arrow && (\n <TooltipArrow side={currentTooltip.side} />\n )}\n </motion.div>\n </motion.div>\n </TooltipPortal>\n )}\n </AnimatePresence>\n );\n}\n\ntype TooltipContextType = {\n content: React.ReactNode;\n setContent: React.Dispatch<React.SetStateAction<React.ReactNode>>;\n arrow: boolean;\n setArrow: React.Dispatch<React.SetStateAction<boolean>>;\n side: Side;\n sideOffset: number;\n align: Align;\n alignOffset: number;\n id: string;\n};\n\nconst TooltipContext = React.createContext<TooltipContextType | undefined>(\n undefined,\n);\n\nconst useTooltip = () => {\n const context = React.useContext(TooltipContext);\n if (!context) {\n throw new Error('useTooltip must be used within a TooltipProvider');\n }\n return context;\n};\n\ntype TooltipProps = {\n children: React.ReactNode;\n side?: Side;\n sideOffset?: number;\n align?: Align;\n alignOffset?: number;\n};\n\nfunction Tooltip({\n children,\n side = 'top',\n sideOffset = 14,\n align = 'center',\n alignOffset = 0,\n}: TooltipProps) {\n const id = React.useId();\n const [content, setContent] = React.useState<React.ReactNode>(null);\n const [arrow, setArrow] = React.useState(true);\n\n return (\n <TooltipContext.Provider\n value={{\n content,\n setContent,\n arrow,\n setArrow,\n side,\n sideOffset,\n align,\n alignOffset,\n id,\n }}\n >\n {children}\n </TooltipContext.Provider>\n );\n}\n\ntype TooltipContentProps = {\n children: React.ReactNode;\n arrow?: boolean;\n};\n\nfunction TooltipContent({ children, arrow = true }: TooltipContentProps) {\n const { setContent, setArrow } = useTooltip();\n React.useEffect(() => {\n setContent(children);\n setArrow(arrow);\n }, [children, setContent, setArrow, arrow]);\n return null;\n}\n\ntype TooltipTriggerProps = {\n children: React.ReactElement;\n};\n\nfunction TooltipTrigger({ children }: TooltipTriggerProps) {\n const { content, side, sideOffset, align, alignOffset, id, arrow } =\n useTooltip();\n const { showTooltip, hideTooltip, currentTooltip } = useGlobalTooltip();\n const triggerRef = React.useRef<HTMLElement>(null);\n\n const handleOpen = React.useCallback(() => {\n if (!triggerRef.current) return;\n const rect = triggerRef.current.getBoundingClientRect();\n showTooltip({\n content,\n rect,\n side,\n sideOffset,\n align,\n alignOffset,\n id,\n arrow,\n });\n }, [showTooltip, content, side, sideOffset, align, alignOffset, id, arrow]);\n\n const handleMouseEnter = React.useCallback(\n (e: React.MouseEvent<HTMLElement>) => {\n (children.props as React.HTMLAttributes<HTMLElement>)?.onMouseEnter?.(e);\n handleOpen();\n },\n [handleOpen, children.props],\n );\n\n const handleMouseLeave = React.useCallback(\n (e: React.MouseEvent<HTMLElement>) => {\n (children.props as React.HTMLAttributes<HTMLElement>)?.onMouseLeave?.(e);\n hideTooltip();\n },\n [hideTooltip, children.props],\n );\n\n const handleFocus = React.useCallback(\n (e: React.FocusEvent<HTMLElement>) => {\n (children.props as React.HTMLAttributes<HTMLElement>)?.onFocus?.(e);\n handleOpen();\n },\n [handleOpen, children.props],\n );\n\n const handleBlur = React.useCallback(\n (e: React.FocusEvent<HTMLElement>) => {\n (children.props as React.HTMLAttributes<HTMLElement>)?.onBlur?.(e);\n hideTooltip();\n },\n [hideTooltip, children.props],\n );\n\n React.useEffect(() => {\n if (currentTooltip?.id !== id) return;\n if (!triggerRef.current) return;\n\n if (currentTooltip.content === content && currentTooltip.arrow === arrow)\n return;\n\n const rect = triggerRef.current.getBoundingClientRect();\n showTooltip({\n content,\n rect,\n side,\n sideOffset,\n align,\n alignOffset,\n id,\n arrow,\n });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [content, arrow, currentTooltip?.id]);\n\n return React.cloneElement(children, {\n ref: triggerRef,\n onMouseEnter: handleMouseEnter,\n onMouseLeave: handleMouseLeave,\n onFocus: handleFocus,\n onBlur: handleBlur,\n 'data-state': currentTooltip?.id === id ? 'open' : 'closed',\n 'data-side': side,\n 'data-align': align,\n 'data-slot': 'tooltip-trigger',\n } as React.HTMLAttributes<HTMLElement>);\n}\n\nexport {\n TooltipProvider,\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n useGlobalTooltip,\n useTooltip,\n type TooltipProviderProps,\n type TooltipProps,\n type TooltipContentProps,\n type TooltipTriggerProps,\n};\n",
|
|
"type": "registry:ui",
|
|
"target": "components/animate-ui/components/tooltip.tsx"
|
|
}
|
|
]
|
|
} |