From 483c50d86c4764b4eec36359ab168811df2ce186 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 29 Aug 2025 11:44:15 -0700 Subject: [PATCH] add tooltip element (based on floating ui) --- frontend/app/element/tooltip.tsx | 144 +++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 frontend/app/element/tooltip.tsx diff --git a/frontend/app/element/tooltip.tsx b/frontend/app/element/tooltip.tsx new file mode 100644 index 0000000000..c15b85060e --- /dev/null +++ b/frontend/app/element/tooltip.tsx @@ -0,0 +1,144 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + FloatingPortal, + autoUpdate, + flip, + offset, + shift, + useFloating, + useHover, + useInteractions, +} from "@floating-ui/react"; +import { cn } from "@/util/util"; +import { useEffect, useRef, useState } from "react"; + +interface TooltipProps { + children: React.ReactNode; + content: React.ReactNode; + placement?: "top" | "bottom" | "left" | "right"; + forceOpen?: boolean; + divClassName?: string; + divStyle?: React.CSSProperties; + divOnClick?: (e: React.MouseEvent) => void; +} + +export function Tooltip({ + children, + content, + placement = "top", + forceOpen = false, + divClassName, + divStyle, + divOnClick, +}: TooltipProps) { + const [isOpen, setIsOpen] = useState(forceOpen); + const [isVisible, setIsVisible] = useState(false); + const timeoutRef = useRef(null); + const prevForceOpenRef = useRef(forceOpen); + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: (open) => { + if (!open && forceOpen) { + return; + } + if (open) { + setIsOpen(true); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => { + setIsVisible(true); + }, 300); + } else { + setIsVisible(false); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => { + setIsOpen(false); + }, 300); + } + }, + placement, + middleware: [ + offset(10), + flip(), + shift({ padding: 12 }), + ], + whileElementsMounted: autoUpdate, + }); + + useEffect(() => { + if (forceOpen) { + setIsOpen(true); + setIsVisible(true); + + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + } else { + if (context.open && !prevForceOpenRef.current) { + // Keep it open if it's being hovered and wasn't forced open before + } else { + setIsVisible(false); + + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + + timeoutRef.current = window.setTimeout(() => { + setIsOpen(false); + }, 300); + } + } + + prevForceOpenRef.current = forceOpen; + }, [forceOpen, context.open]); + + useEffect(() => { + return () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); + + const hover = useHover(context); + const { getReferenceProps, getFloatingProps } = useInteractions([hover]); + + return ( + <> +
+ {children} +
+ {isOpen && ( + +
+ {content} +
+
+ )} + + ); +} \ No newline at end of file