From 56abf8230efe901346b03da060e68b617bb50d4e Mon Sep 17 00:00:00 2001 From: yyh Date: Sat, 9 May 2026 12:25:55 +0800 Subject: [PATCH] refactor(web): remove legacy tooltip implementation --- .../components/base/tooltip/TooltipManager.ts | 27 -- .../tooltip/__tests__/TooltipManager.spec.ts | 129 ------- .../base/tooltip/__tests__/content.spec.tsx | 49 --- .../base/tooltip/__tests__/index.spec.tsx | 333 ------------------ web/app/components/base/tooltip/content.tsx | 22 -- .../components/base/tooltip/index.stories.tsx | 60 ---- web/app/components/base/tooltip/index.tsx | 231 ------------ 7 files changed, 851 deletions(-) delete mode 100644 web/app/components/base/tooltip/TooltipManager.ts delete mode 100644 web/app/components/base/tooltip/__tests__/TooltipManager.spec.ts delete mode 100644 web/app/components/base/tooltip/__tests__/content.spec.tsx delete mode 100644 web/app/components/base/tooltip/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/tooltip/content.tsx delete mode 100644 web/app/components/base/tooltip/index.stories.tsx delete mode 100644 web/app/components/base/tooltip/index.tsx 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)