UNPKG

lightswind

Version:

A professionally designed animate react component library & templates market that brings together functionality, accessibility, and beautiful aesthetics for modern applications.

331 lines (330 loc) 15.2 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import * as React from "react"; import { cn } from "../lib/utils"; // Assuming utils.ts is in lib/ import { motion, AnimatePresence } from "framer-motion"; // Import Variants and Easing const TooltipContext = React.createContext(undefined); // --- Tooltip Component --- const Tooltip = ({ children, content, defaultOpen = false, open: controlledOpen, onOpenChange, delayDuration = 300, hideDelay = 100, side = "top", align = "center", sideOffset = 8, variant = "default", hideArrow = false, maxWidth = "20rem", asChild = false, disabled = false, }) => { // Manage uncontrolled open state const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen); // Determine if the component is controlled or uncontrolled const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : uncontrolledOpen; // Ref for the trigger element, passed via context to TooltipContentDisplay const triggerRef = React.useRef(null); // Function to update the open state, handling both controlled and uncontrolled modes const setOpen = React.useCallback((value) => { if (!isControlled) { setUncontrolledOpen(value); } if (onOpenChange) { // Calculate the new value if a function is passed const newValue = typeof value === "function" ? value(open) : value; onOpenChange(newValue); } }, [isControlled, onOpenChange, open]); // Refs for managing show/hide timeouts const showTimeoutRef = React.useRef(null); const hideTimeoutRef = React.useRef(null); // Memoize configuration object to prevent unnecessary re-renders const config = React.useMemo(() => ({ side, align, sideOffset, variant, hideArrow, maxWidth, }), [side, align, sideOffset, variant, hideArrow, maxWidth]); // Clean up any pending timeouts on component unmount React.useEffect(() => { return () => { if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current); if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); }; }, []); // Memoize the context value to prevent unnecessary re-renders of consumers const contextValue = React.useMemo(() => ({ open, setOpen, content, config, triggerRef, // Include triggerRef in context value }), [open, setOpen, content, config, triggerRef]); return (_jsx(TooltipContext.Provider, { value: contextValue, children: _jsxs("div", { className: "relative inline-block", children: [disabled ? children : (_jsx(TooltipTrigger, { delayDuration: delayDuration, hideDelay: hideDelay, asChild: asChild, triggerRef: triggerRef, children: children })), _jsx(TooltipContentDisplay, {})] }) })); }; // --- TooltipTrigger Component --- const TooltipTrigger = React.forwardRef(({ children, delayDuration, hideDelay, asChild = false, triggerRef }, ref) => { const context = React.useContext(TooltipContext); if (!context) { throw new Error("TooltipTrigger must be used within a Tooltip"); } const { setOpen } = context; const showTimeoutRef = React.useRef(null); const hideTimeoutRef = React.useRef(null); // Handle mouse enter event const handleMouseEnter = () => { if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } showTimeoutRef.current = setTimeout(() => { setOpen(true); }, delayDuration); }; // Handle mouse leave event const handleMouseLeave = () => { if (showTimeoutRef.current) { clearTimeout(showTimeoutRef.current); showTimeoutRef.current = null; } hideTimeoutRef.current = setTimeout(() => { setOpen(false); }, hideDelay); }; // Combined ref to set both the forwarded ref and the internal triggerRef const combinedRef = React.useCallback((node) => { if (typeof ref === 'function') { ref(node); } else if (ref) { ref.current = node; } triggerRef.current = node; // Crucial: assign the actual DOM node to the context's triggerRef }, [ref, triggerRef]); // Props to be applied to the trigger element const triggerProps = { ref: combinedRef, // Use the combined ref onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onFocus: handleMouseEnter, onBlur: handleMouseLeave, }; // If asChild is true, clone props onto the child element if (asChild && React.isValidElement(children)) { return React.cloneElement(children, triggerProps); } // Default rendering if asChild is false return (_jsx("div", { className: "inline-block relative" // Keeps the trigger as a block for correct positioning , ...triggerProps, children: children })); }); TooltipTrigger.displayName = "TooltipTrigger"; // --- TooltipContentDisplay Component --- // This component renders the actual tooltip content and handles its positioning. const TooltipContentDisplay = () => { const context = React.useContext(TooltipContext); if (!context) { throw new Error("TooltipContentDisplay must be used within a Tooltip"); } const { open, content, config, triggerRef } = context; // Get triggerRef from context const [position, setPosition] = React.useState({ x: 0, y: 0 }); const contentRef = React.useRef(null); // Define animation variants based on side const getAnimationVariants = React.useCallback(() => { const { side } = config; const distance = 5; return { hidden: { opacity: 0, x: side === 'left' ? distance : side === 'right' ? -distance : 0, y: side === 'top' ? distance : side === 'bottom' ? -distance : 0, }, visible: { opacity: 1, x: 0, y: 0, transition: { duration: 0.15, ease: "easeOut" } // Cast "easeOut" to Easing }, exit: { opacity: 0, transition: { duration: 0.1, ease: "easeIn" } // Cast "easeIn" to Easing } }; }, [config]); // Callback to update the tooltip's position const updatePosition = React.useCallback(() => { if (!contentRef.current || !triggerRef.current) return; const triggerRect = triggerRef.current.getBoundingClientRect(); const contentRect = contentRef.current.getBoundingClientRect(); // Get the rect of the closest relatively positioned parent (the outer Tooltip div) // We assume the parentElement of contentRef.current is the `relative inline-block` container. const parentRect = contentRef.current.parentElement.getBoundingClientRect(); let x = 0; let y = 0; const { side, align, sideOffset } = config; // Calculate initial position based on side, relative to the trigger switch (side) { case "top": y = triggerRect.top - contentRect.height - sideOffset; break; case "bottom": y = triggerRect.bottom + sideOffset; break; case "left": x = triggerRect.left - contentRect.width - sideOffset; break; case "right": x = triggerRect.right + sideOffset; break; } // Adjust for alignment (horizontal for top/bottom, vertical for left/right) if (side === "top" || side === "bottom") { switch (align) { case "start": x = triggerRect.left; break; case "end": x = triggerRect.right - contentRect.width; break; default: // center x = triggerRect.left + (triggerRect.width / 2) - (contentRect.width / 2); break; } } else if (side === "left" || side === "right") { switch (align) { case "start": y = triggerRect.top; break; case "end": y = triggerRect.bottom - contentRect.height; break; default: // center y = triggerRect.top + (triggerRect.height / 2) - (contentRect.height / 2); break; } } // Convert viewport coordinates to coordinates relative to the parentRect // The tooltip will be absolutely positioned within the parent container setPosition({ x: x - parentRect.left, y: y - parentRect.top, }); }, [config, triggerRef]); // Effect to update position when tooltip becomes open or window resizes React.useEffect(() => { if (open) { // Use requestAnimationFrame or setTimeout for next tick to ensure contentRect is accurate // after AnimatePresence renders the div const id = requestAnimationFrame(updatePosition); // Better than setTimeout for layout // Re-calculate position on window resize to keep it attached window.addEventListener('resize', updatePosition); return () => { cancelAnimationFrame(id); window.removeEventListener('resize', updatePosition); }; } }, [open, updatePosition]); // Arrow positioning based on side and alignment const getArrowStyle = React.useCallback(() => { const { side, align } = config; const arrowSize = 8; // Size of the square div for the arrow let style = { position: 'absolute', width: arrowSize, height: arrowSize, transform: 'rotate(45deg)', // Rotate to form a diamond shape zIndex: -1, // Ensure arrow is behind the content }; // Calculate position of the arrow relative to the tooltip content box switch (side) { case "top": // Arrow points down from the bottom of tooltip style.bottom = -arrowSize / 2; if (align === 'center') style.left = '50%'; else if (align === 'start') style.left = '10%'; // 10% from start of tooltip content else style.right = '10%'; // 10% from end of tooltip content (using right instead of left for end) break; case "bottom": // Arrow points up from the top of tooltip style.top = -arrowSize / 2; if (align === 'center') style.left = '50%'; else if (align === 'start') style.left = '10%'; else style.right = '10%'; break; case "left": // Arrow points right from the right of tooltip style.right = -arrowSize / 2; if (align === 'center') style.top = '50%'; else if (align === 'start') style.top = '10%'; else style.bottom = '10%'; break; case "right": // Arrow points left from the left of tooltip style.left = -arrowSize / 2; if (align === 'center') style.top = '50%'; else if (align === 'start') style.top = '10%'; else style.bottom = '10%'; break; } // For start/end alignment, ensure the transform origin is correct if (align !== 'center') { if (side === 'top' || side === 'bottom') { style.transformOrigin = (align === 'start' ? 'left center' : 'right center'); if (align === 'start') style.transform = 'translateX(50%) rotate(45deg)'; // Adjust to push arrow more towards edge else style.transform = 'translateX(-50%) rotate(45deg)'; // Adjust to push arrow more towards edge } else { // left or right style.transformOrigin = (align === 'start' ? 'top center' : 'bottom center'); if (align === 'start') style.transform = 'translateY(50%) rotate(45deg)'; else style.transform = 'translateY(-50%) rotate(45deg)'; } } return style; }, [config]); // Get variant-based background and text colors for tooltip body and arrow const getVariantClasses = React.useCallback(() => { const { variant } = config; switch (variant) { case 'info': return 'bg-blue-500 text-white'; case 'success': return 'bg-green-500 text-white'; case 'warning': return 'bg-yellow-500 text-black'; // Often warnings have black text case 'error': return 'bg-red-500 text-white'; default: return 'bg-popover text-popover-foreground border border-gray-200 dark:border-gray-700'; // Default with border } }, [config]); // Don't render if not open if (!open) return null; return (_jsx(AnimatePresence, { children: _jsxs(motion.div, { ref: contentRef, style: { position: 'absolute', // Position absolute relative to the nearest positioned ancestor top: position.y, left: position.x, maxWidth: config.maxWidth, }, initial: "hidden", animate: "visible", exit: "exit", variants: getAnimationVariants(), className: cn("z-50 rounded px-3 py-1.5 text-xs shadow-md", getVariantClasses()), children: [!config.hideArrow && (_jsx("div", { className: cn("w-2 h-2 absolute", getVariantClasses(), // Apply variant classes for arrow background color "before:content-[''] before:absolute before:inset-0 before:bg-inherit before:rounded-sm" // Pseudo-element for actual arrow body ), style: getArrowStyle() })), content] }) })); }; // Re-export as named export export { Tooltip, TooltipTrigger }; // For legacy compatibility (if needed, otherwise remove) export const Tooltips = Tooltip; // Deprecated TooltipContent, kept for backward compatibility with a warning export const TooltipContent = React.forwardRef((props, _ref) => { console.warn("TooltipContent is deprecated. Use the Tooltip component with content prop instead."); return _jsx("div", { ...props }); }); TooltipContent.displayName = "TooltipContent"; // Backward compatibility for TooltipProvider (if needed, otherwise remove) export const TooltipProvider = ({ children }) => { return _jsx(_Fragment, { children: children }); };