diff --git a/web/app/components/base/tooltip/TooltipManager.ts b/web/app/components/base/tooltip/TooltipManager.ts
deleted file mode 100644
index b0138af4b3..0000000000
--- a/web/app/components/base/tooltip/TooltipManager.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-class TooltipManager {
- private activeCloser: (() => void) | null = null
-
- register(closeFn: () => void) {
- if (this.activeCloser)
- this.activeCloser()
- this.activeCloser = closeFn
- }
-
- clear(closeFn: () => void) {
- if (this.activeCloser === closeFn)
- this.activeCloser = null
- }
-
- /**
- * Closes the currently active tooltip by calling its closer function
- * and clearing the reference to it
- */
- closeActiveTooltip() {
- if (this.activeCloser) {
- this.activeCloser()
- this.activeCloser = null
- }
- }
-}
-
-export const tooltipManager = new TooltipManager()
diff --git a/web/app/components/base/tooltip/__tests__/TooltipManager.spec.ts b/web/app/components/base/tooltip/__tests__/TooltipManager.spec.ts
deleted file mode 100644
index 406c48259a..0000000000
--- a/web/app/components/base/tooltip/__tests__/TooltipManager.spec.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { tooltipManager } from '../TooltipManager'
-
-describe('TooltipManager', () => {
- // Test the singleton instance directly
- let manager: typeof tooltipManager
-
- beforeEach(() => {
- // Get fresh reference to the singleton
- manager = tooltipManager
- // Clean up any active tooltip by calling closeActiveTooltip
- // This ensures each test starts with a clean state
- manager.closeActiveTooltip()
- })
-
- describe('register', () => {
- it('should register a close function', () => {
- const closeFn = vi.fn()
- manager.register(closeFn)
- expect(closeFn).not.toHaveBeenCalled()
- })
-
- it('should call the existing close function when registering a new one', () => {
- const firstCloseFn = vi.fn()
- const secondCloseFn = vi.fn()
-
- manager.register(firstCloseFn)
- manager.register(secondCloseFn)
-
- expect(firstCloseFn).toHaveBeenCalledTimes(1)
- expect(secondCloseFn).not.toHaveBeenCalled()
- })
-
- it('should replace the active closer with the new one', () => {
- const firstCloseFn = vi.fn()
- const secondCloseFn = vi.fn()
-
- // Register first function
- manager.register(firstCloseFn)
-
- // Register second function - this should call firstCloseFn and replace it
- manager.register(secondCloseFn)
-
- // Verify firstCloseFn was called during register (replacement behavior)
- expect(firstCloseFn).toHaveBeenCalledTimes(1)
-
- // Now close the active tooltip - this should call secondCloseFn
- manager.closeActiveTooltip()
-
- // Verify secondCloseFn was called, not firstCloseFn
- expect(secondCloseFn).toHaveBeenCalledTimes(1)
- })
- })
-
- describe('clear', () => {
- it('should not clear if the close function does not match', () => {
- const closeFn = vi.fn()
- const otherCloseFn = vi.fn()
-
- manager.register(closeFn)
- manager.clear(otherCloseFn)
-
- manager.closeActiveTooltip()
- expect(closeFn).toHaveBeenCalledTimes(1)
- })
-
- it('should clear the close function if it matches', () => {
- const closeFn = vi.fn()
-
- manager.register(closeFn)
- manager.clear(closeFn)
-
- manager.closeActiveTooltip()
- expect(closeFn).not.toHaveBeenCalled()
- })
-
- it('should not call the close function when clearing', () => {
- const closeFn = vi.fn()
-
- manager.register(closeFn)
- manager.clear(closeFn)
-
- expect(closeFn).not.toHaveBeenCalled()
- })
- })
-
- describe('closeActiveTooltip', () => {
- it('should do nothing when no active closer is registered', () => {
- expect(() => manager.closeActiveTooltip()).not.toThrow()
- })
-
- it('should call the active closer function', () => {
- const closeFn = vi.fn()
- manager.register(closeFn)
-
- manager.closeActiveTooltip()
-
- expect(closeFn).toHaveBeenCalledTimes(1)
- })
-
- it('should clear the active closer after calling it', () => {
- const closeFn = vi.fn()
- manager.register(closeFn)
-
- manager.closeActiveTooltip()
- manager.closeActiveTooltip()
-
- expect(closeFn).toHaveBeenCalledTimes(1)
- })
-
- it('should handle multiple register and close cycles', () => {
- const closeFn1 = vi.fn()
- const closeFn2 = vi.fn()
- const closeFn3 = vi.fn()
-
- manager.register(closeFn1)
- manager.closeActiveTooltip()
-
- manager.register(closeFn2)
- manager.closeActiveTooltip()
-
- manager.register(closeFn3)
- manager.closeActiveTooltip()
-
- expect(closeFn1).toHaveBeenCalledTimes(1)
- expect(closeFn2).toHaveBeenCalledTimes(1)
- expect(closeFn3).toHaveBeenCalledTimes(1)
- })
- })
-})
diff --git a/web/app/components/base/tooltip/__tests__/content.spec.tsx b/web/app/components/base/tooltip/__tests__/content.spec.tsx
deleted file mode 100644
index fa5d86756e..0000000000
--- a/web/app/components/base/tooltip/__tests__/content.spec.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { describe, expect, it, vi } from 'vitest'
-import { ToolTipContent } from '../content'
-
-describe('ToolTipContent', () => {
- it('should render children correctly', () => {
- render(
-
- Tooltip body text
- ,
- )
- expect(screen.getByTestId('tooltip-content')).toBeInTheDocument()
- expect(screen.getByTestId('tooltip-content-body')).toHaveTextContent('Tooltip body text')
- expect(screen.queryByTestId('tooltip-content-title')).not.toBeInTheDocument()
- expect(screen.queryByTestId('tooltip-content-action')).not.toBeInTheDocument()
- })
-
- it('should render title when provided', () => {
- render(
-
- Tooltip body text
- ,
- )
- expect(screen.getByTestId('tooltip-content-title')).toHaveTextContent('Tooltip Title')
- })
-
- it('should render action when provided', () => {
- render(
- Action Text}>
- Tooltip body text
- ,
- )
- expect(screen.getByTestId('tooltip-content-action')).toHaveTextContent('Action Text')
- })
-
- it('should handle action click', async () => {
- const user = userEvent.setup()
- const handleActionClick = vi.fn()
- render(
- Action Text}>
- Tooltip body text
- ,
- )
-
- await user.click(screen.getByText('Action Text'))
- expect(handleActionClick).toHaveBeenCalledTimes(1)
- })
-})
diff --git a/web/app/components/base/tooltip/__tests__/index.spec.tsx b/web/app/components/base/tooltip/__tests__/index.spec.tsx
deleted file mode 100644
index 39f8f1b503..0000000000
--- a/web/app/components/base/tooltip/__tests__/index.spec.tsx
+++ /dev/null
@@ -1,333 +0,0 @@
-import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
-import * as React from 'react'
-import Tooltip from '../index'
-import { tooltipManager } from '../TooltipManager'
-
-afterEach(() => {
- cleanup()
- vi.clearAllTimers()
- vi.useRealTimers()
-})
-
-describe('Tooltip', () => {
- describe('Rendering', () => {
- it('should render default tooltip with question icon', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
- expect(trigger).not.toBeNull()
- expect(trigger?.querySelector('svg')).not.toBeNull() // question icon
- })
-
- it('should render with custom children', () => {
- const { getByText } = render(
-
-
- ,
- )
- expect(getByText('Hover me').textContent).toBe('Hover me')
- })
-
- it('should render correctly when asChild is false', () => {
- const { container } = render(
-
- Trigger
- ,
- )
- const trigger = container.querySelector('.custom-parent-trigger')
- expect(trigger).not.toBeNull()
- })
-
- it('should render with a fallback question icon when children are null', () => {
- const { container } = render(
-
- {null}
- ,
- )
- const trigger = container.querySelector('.custom-fallback-trigger')
- expect(trigger).not.toBeNull()
- expect(trigger?.querySelector('svg')).not.toBeNull()
- })
- })
-
- describe('Disabled state', () => {
- it('should not show tooltip when disabled', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
- })
- })
-
- describe('Trigger methods', () => {
- beforeEach(() => {
- vi.useFakeTimers()
- })
-
- it('should open on hover when triggerMethod is hover', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect(screen.queryByText('Tooltip content')).toBeInTheDocument()
- })
-
- it('should close on mouse leave when triggerMethod is hover and needsDelay is false', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.mouseEnter(trigger!)
- fireEvent.mouseLeave(trigger!)
- })
- expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
- })
-
- it('should toggle on click when triggerMethod is click', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.click(trigger!)
- })
- expect(screen.queryByText('Tooltip content')).toBeInTheDocument()
-
- // Test toggle off
- act(() => {
- fireEvent.click(trigger!)
- })
- expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
- })
-
- it('should do nothing on mouse enter if triggerMethod is click', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
- })
-
- it('should delay closing on mouse leave when needsDelay is true', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
-
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
-
- act(() => {
- fireEvent.mouseLeave(trigger!)
- })
- // Shouldn't close immediately
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
-
- act(() => {
- vi.advanceTimersByTime(350)
- })
- // Should close after delay
- expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
- })
-
- it('should not close if mouse enters popup before delay finishes', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
-
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
-
- const popup = screen.getByText('Tooltip content')
- expect(popup).toBeInTheDocument()
-
- act(() => {
- fireEvent.mouseLeave(trigger!)
- })
-
- act(() => {
- vi.advanceTimersByTime(150)
- // Simulate mouse entering popup area itself during the delay timeframe
- fireEvent.mouseEnter(popup)
- })
-
- act(() => {
- vi.advanceTimersByTime(200) // Complete the 300ms original delay
- })
-
- // Should still be open because we are hovering the popup
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
-
- // Now mouse leaves popup
- act(() => {
- fireEvent.mouseLeave(popup)
- })
-
- act(() => {
- vi.advanceTimersByTime(350)
- })
- // Should now close
- expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
- })
-
- it('should do nothing on mouse enter/leave of popup when triggerMethod is not hover', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
-
- act(() => {
- fireEvent.click(trigger!)
- })
-
- const popup = screen.getByText('Tooltip content')
-
- act(() => {
- fireEvent.mouseEnter(popup)
- fireEvent.mouseLeave(popup)
- vi.advanceTimersByTime(350)
- })
-
- // Should still be open because click method requires another click to close, not hover leave
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
- })
-
- it('should clear close timeout if trigger is hovered again before delay finishes', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
-
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
-
- act(() => {
- fireEvent.mouseLeave(trigger!)
- })
-
- act(() => {
- vi.advanceTimersByTime(150)
- // Re-hover trigger before it closes
- fireEvent.mouseEnter(trigger!)
- })
-
- act(() => {
- vi.advanceTimersByTime(200) // Original 300ms would be up
- })
-
- // Should still be open because we reset it
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
- })
-
- it('should test clear close timeout if trigger is hovered again before delay finishes and isHoverPopupRef is true', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
-
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
-
- const popup = screen.getByText('Tooltip content')
- expect(popup).toBeInTheDocument()
-
- act(() => {
- fireEvent.mouseEnter(popup)
- fireEvent.mouseLeave(trigger!)
- })
-
- act(() => {
- vi.advanceTimersByTime(350)
- })
-
- // Should still be open because we are hovering the popup
- expect(screen.getByText('Tooltip content')).toBeInTheDocument()
- })
- })
-
- describe('TooltipManager', () => {
- it('should close active tooltips when triggered centrally, overriding other closes', () => {
- const triggerClassName1 = 'custom-trigger-1'
- const triggerClassName2 = 'custom-trigger-2'
-
- const { container } = render(
-
-
-
-
,
- )
-
- const trigger1 = container.querySelector(`.${triggerClassName1}`)
- const trigger2 = container.querySelector(`.${triggerClassName2}`)
-
- expect(trigger2).not.toBeNull()
-
- // Open first tooltip
- act(() => {
- fireEvent.mouseEnter(trigger1!)
- })
- expect(screen.queryByText('Tooltip content 1')).toBeInTheDocument()
-
- // TooltipManager should keep track of it
- // Next, immediately open the second one without leaving first (e.g., via TooltipManager)
- // TooltipManager registers the newest one and closes the old one when doing full external operations, but internally the manager allows direct closing
-
- act(() => {
- tooltipManager.closeActiveTooltip()
- })
-
- expect(screen.queryByText('Tooltip content 1')).not.toBeInTheDocument()
-
- // Safe to call again
- expect(() => tooltipManager.closeActiveTooltip()).not.toThrow()
- })
- })
-
- describe('Styling and positioning', () => {
- it('should apply custom trigger className', () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
- expect(trigger?.className).toContain('custom-trigger')
- })
-
- it('should pass triggerTestId to the fallback icon wrapper', () => {
- render()
- expect(screen.getByTestId('test-tooltip-icon')).toBeInTheDocument()
- })
-
- it('should apply custom popup className', async () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render()
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect((await screen.findByText('Tooltip content'))?.className).toContain('custom-popup')
- })
-
- it('should apply noDecoration when specified', async () => {
- const triggerClassName = 'custom-trigger'
- const { container } = render(
- ,
- )
- const trigger = container.querySelector(`.${triggerClassName}`)
- act(() => {
- fireEvent.mouseEnter(trigger!)
- })
- expect((await screen.findByText('Tooltip content'))?.className).not.toContain('bg-components-panel-bg')
- })
- })
-})
diff --git a/web/app/components/base/tooltip/content.tsx b/web/app/components/base/tooltip/content.tsx
deleted file mode 100644
index 191ee933f1..0000000000
--- a/web/app/components/base/tooltip/content.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { FC, PropsWithChildren, ReactNode } from 'react'
-
-type ToolTipContentProps = {
- title?: ReactNode
- action?: ReactNode
-} & PropsWithChildren
-
-export const ToolTipContent: FC = ({
- title,
- action,
- children,
-}) => {
- return (
-
- {!!title && (
-
{title}
- )}
-
{children}
- {!!action &&
{action}
}
-
- )
-}
diff --git a/web/app/components/base/tooltip/index.stories.tsx b/web/app/components/base/tooltip/index.stories.tsx
deleted file mode 100644
index 69d0c5d2b6..0000000000
--- a/web/app/components/base/tooltip/index.stories.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import Tooltip from '.'
-
-const TooltipGrid = () => {
- return (
-
-
Hover tooltips
-
-
-
-
-
-
- Right tooltip
-
-
-
-
Click tooltips
-
-
-
-
-
-
- Plain content
-
-
-
-
- )
-}
-
-const meta = {
- title: 'Base/Feedback/Tooltip',
- component: TooltipGrid,
- parameters: {
- layout: 'centered',
- docs: {
- description: {
- component: 'Portal-based tooltip component supporting hover and click triggers, custom placements, and decorated content.',
- },
- },
- },
- tags: ['autodocs'],
-} satisfies Meta
-
-export default meta
-type Story = StoryObj
-
-export const Playground: Story = {}
diff --git a/web/app/components/base/tooltip/index.tsx b/web/app/components/base/tooltip/index.tsx
deleted file mode 100644
index 85c63cdeaf..0000000000
--- a/web/app/components/base/tooltip/index.tsx
+++ /dev/null
@@ -1,231 +0,0 @@
-'use client'
-import type { Placement } from '@langgenius/dify-ui/popover'
-/**
- * @deprecated Use `@langgenius/dify-ui/tooltip` instead.
- * This component will be removed after migration is complete.
- * See: https://github.com/langgenius/dify/issues/32767
- */
-import type { FC } from 'react'
-import { cn } from '@langgenius/dify-ui/cn'
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from '@langgenius/dify-ui/popover'
-import { RiQuestionLine } from '@remixicon/react'
-import { useBoolean } from 'ahooks'
-import * as React from 'react'
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { tooltipManager } from './TooltipManager'
-
-type TooltipOffset = number | {
- mainAxis?: number
- crossAxis?: number
-}
-
-type TooltipProps = {
- position?: Placement
- triggerMethod?: 'hover' | 'click'
- triggerClassName?: string
- triggerTestId?: string
- disabled?: boolean
- popupContent?: React.ReactNode
- children?: React.ReactNode
- popupClassName?: string
- portalContentClassName?: string
- noDecoration?: boolean
- offset?: TooltipOffset
- needsDelay?: boolean
- asChild?: boolean
-}
-
-const Tooltip: FC = ({
- position = 'top',
- triggerMethod = 'hover',
- triggerClassName,
- triggerTestId,
- disabled = false,
- popupContent,
- children,
- popupClassName,
- portalContentClassName,
- noDecoration,
- offset,
- asChild = true,
- needsDelay = true,
-}) => {
- const [open, setOpen] = useState(false)
- const resolvedOffset = offset ?? 8
- const sideOffset = typeof resolvedOffset === 'number' ? resolvedOffset : (resolvedOffset.mainAxis ?? 0)
- const alignOffset = typeof resolvedOffset === 'number' ? 0 : (resolvedOffset.crossAxis ?? 0)
- const [isHoverPopup, {
- setTrue: setHoverPopup,
- setFalse: setNotHoverPopup,
- }] = useBoolean(false)
-
- const isHoverPopupRef = useRef(isHoverPopup)
- useEffect(() => {
- isHoverPopupRef.current = isHoverPopup
- }, [isHoverPopup])
-
- const [isHoverTrigger, {
- setTrue: setHoverTrigger,
- setFalse: setNotHoverTrigger,
- }] = useBoolean(false)
-
- const isHoverTriggerRef = useRef(isHoverTrigger)
- useEffect(() => {
- isHoverTriggerRef.current = isHoverTrigger
- }, [isHoverTrigger])
-
- const closeTimeoutRef = useRef | null>(null)
- const clearCloseTimeout = useCallback(() => {
- if (closeTimeoutRef.current) {
- clearTimeout(closeTimeoutRef.current)
- closeTimeoutRef.current = null
- }
- }, [])
-
- useEffect(() => {
- return () => {
- clearCloseTimeout()
- }
- }, [clearCloseTimeout])
-
- const close = () => setOpen(false)
- const handleOpenChange = (nextOpen: boolean) => {
- if (disabled) {
- setOpen(false)
- return
- }
- if (triggerMethod === 'click')
- setOpen(nextOpen)
- else if (!nextOpen)
- setOpen(false)
- }
-
- const handleLeave = (isTrigger: boolean) => {
- if (isTrigger)
- setNotHoverTrigger()
- else
- setNotHoverPopup()
-
- // give time to move to the popup
- if (needsDelay) {
- clearCloseTimeout()
- closeTimeoutRef.current = setTimeout(() => {
- closeTimeoutRef.current = null
- if (!isHoverPopupRef.current && !isHoverTriggerRef.current) {
- setOpen(false)
- tooltipManager.clear(close)
- }
- }, 300)
- }
- else {
- clearCloseTimeout()
- setOpen(false)
- tooltipManager.clear(close)
- }
- }
- const handleTriggerMouseEnter = () => {
- if (triggerMethod === 'hover') {
- clearCloseTimeout()
- setHoverTrigger()
- tooltipManager.register(close)
- setOpen(true)
- }
- }
- const handleTriggerMouseLeave = () => {
- if (triggerMethod === 'hover')
- handleLeave(true)
- }
- const handlePopupMouseEnter = () => {
- if (triggerMethod === 'hover') {
- clearCloseTimeout()
- setHoverPopup()
- }
- }
- const handlePopupMouseLeave = () => {
- if (triggerMethod === 'hover')
- handleLeave(false)
- }
-
- const fallbackTrigger = (
-
-
-
- )
- const triggerContent = children || fallbackTrigger
- const childElement = React.isValidElement>(triggerContent)
- ? triggerContent
- : fallbackTrigger
- const nativeButton = typeof childElement.type !== 'string' || childElement.type === 'button'
-
- const renderAsChildTrigger = () => {
- const childProps = childElement.props
- return React.cloneElement(childElement, {
- onMouseEnter: (event: React.MouseEvent) => {
- childProps.onMouseEnter?.(event)
- handleTriggerMouseEnter()
- },
- onMouseLeave: (event: React.MouseEvent) => {
- childProps.onMouseLeave?.(event)
- handleTriggerMouseLeave()
- },
- })
- }
- const effectiveOpen = !disabled && open
-
- return (
-
- {asChild
- ? (
-
- )
- : (
-
- )}
- >
- {triggerContent}
-
- )}
- {effectiveOpen && !!popupContent && (
-
- {popupContent}
-
- )}
-
- )
-}
-
-export default React.memo(Tooltip)