mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 21:28:25 +08:00
refactor(web): remove legacy tooltip implementation
This commit is contained in:
parent
7d27f5d6c4
commit
56abf8230e
@ -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()
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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(
|
||||
<ToolTipContent>
|
||||
<span>Tooltip body text</span>
|
||||
</ToolTipContent>,
|
||||
)
|
||||
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(
|
||||
<ToolTipContent title="Tooltip Title">
|
||||
<span>Tooltip body text</span>
|
||||
</ToolTipContent>,
|
||||
)
|
||||
expect(screen.getByTestId('tooltip-content-title')).toHaveTextContent('Tooltip Title')
|
||||
})
|
||||
|
||||
it('should render action when provided', () => {
|
||||
render(
|
||||
<ToolTipContent action={<span>Action Text</span>}>
|
||||
<span>Tooltip body text</span>
|
||||
</ToolTipContent>,
|
||||
)
|
||||
expect(screen.getByTestId('tooltip-content-action')).toHaveTextContent('Action Text')
|
||||
})
|
||||
|
||||
it('should handle action click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleActionClick = vi.fn()
|
||||
render(
|
||||
<ToolTipContent action={<span onClick={handleActionClick}>Action Text</span>}>
|
||||
<span>Tooltip body text</span>
|
||||
</ToolTipContent>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Action Text'))
|
||||
expect(handleActionClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -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(<Tooltip popupContent="Tooltip content" triggerClassName={triggerClassName} />)
|
||||
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(
|
||||
<Tooltip popupContent="Tooltip content">
|
||||
<button>Hover me</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
expect(getByText('Hover me').textContent).toBe('Hover me')
|
||||
})
|
||||
|
||||
it('should render correctly when asChild is false', () => {
|
||||
const { container } = render(
|
||||
<Tooltip popupContent="Tooltip" asChild={false} triggerClassName="custom-parent-trigger">
|
||||
<span>Trigger</span>
|
||||
</Tooltip>,
|
||||
)
|
||||
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(
|
||||
<Tooltip popupContent="Tooltip" triggerClassName="custom-fallback-trigger">
|
||||
{null}
|
||||
</Tooltip>,
|
||||
)
|
||||
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(<Tooltip popupContent="Tooltip content" disabled triggerClassName={triggerClassName} />)
|
||||
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(<Tooltip popupContent="Tooltip content" triggerClassName={triggerClassName} />)
|
||||
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(<Tooltip popupContent="Tooltip content" triggerClassName={triggerClassName} needsDelay={false} />)
|
||||
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(<Tooltip popupContent="Tooltip content" triggerMethod="click" triggerClassName={triggerClassName} />)
|
||||
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(<Tooltip popupContent="Tooltip content" triggerMethod="click" triggerClassName={triggerClassName} />)
|
||||
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(<Tooltip popupContent="Tooltip content" triggerMethod="hover" needsDelay triggerClassName={triggerClassName} />)
|
||||
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(<Tooltip popupContent="Tooltip content" triggerMethod="hover" needsDelay triggerClassName={triggerClassName} />)
|
||||
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(<Tooltip popupContent="Tooltip content" triggerMethod="click" needsDelay triggerClassName={triggerClassName} />)
|
||||
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(<Tooltip popupContent="Tooltip content" triggerMethod="hover" needsDelay triggerClassName={triggerClassName} />)
|
||||
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(<Tooltip popupContent="Tooltip content" triggerMethod="hover" needsDelay triggerClassName={triggerClassName} />)
|
||||
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(
|
||||
<div>
|
||||
<Tooltip popupContent="Tooltip content 1" triggerMethod="hover" triggerClassName={triggerClassName1} />
|
||||
<Tooltip popupContent="Tooltip content 2" triggerMethod="hover" triggerClassName={triggerClassName2} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
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(<Tooltip popupContent="Tooltip content" triggerClassName={triggerClassName} />)
|
||||
const trigger = container.querySelector(`.${triggerClassName}`)
|
||||
expect(trigger?.className).toContain('custom-trigger')
|
||||
})
|
||||
|
||||
it('should pass triggerTestId to the fallback icon wrapper', () => {
|
||||
render(<Tooltip popupContent="Tooltip content" triggerTestId="test-tooltip-icon" />)
|
||||
expect(screen.getByTestId('test-tooltip-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom popup className', async () => {
|
||||
const triggerClassName = 'custom-trigger'
|
||||
const { container } = render(<Tooltip popupContent="Tooltip content" triggerClassName={triggerClassName} popupClassName="custom-popup" />)
|
||||
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(
|
||||
<Tooltip
|
||||
popupContent="Tooltip content"
|
||||
triggerClassName={triggerClassName}
|
||||
noDecoration
|
||||
/>,
|
||||
)
|
||||
const trigger = container.querySelector(`.${triggerClassName}`)
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(trigger!)
|
||||
})
|
||||
expect((await screen.findByText('Tooltip content'))?.className).not.toContain('bg-components-panel-bg')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,22 +0,0 @@
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react'
|
||||
|
||||
type ToolTipContentProps = {
|
||||
title?: ReactNode
|
||||
action?: ReactNode
|
||||
} & PropsWithChildren
|
||||
|
||||
export const ToolTipContent: FC<ToolTipContentProps> = ({
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-[180px]" data-testid="tooltip-content">
|
||||
{!!title && (
|
||||
<div className="mb-1.5 font-semibold text-text-secondary" data-testid="tooltip-content-title">{title}</div>
|
||||
)}
|
||||
<div className="mb-1.5 text-text-tertiary" data-testid="tooltip-content-body">{children}</div>
|
||||
{!!action && <div className="cursor-pointer text-text-accent" data-testid="tooltip-content-action">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import Tooltip from '.'
|
||||
|
||||
const TooltipGrid = () => {
|
||||
return (
|
||||
<div className="flex w-full max-w-xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">Hover tooltips</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Tooltip popupContent="Helpful hint explaining the setting.">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
Hover me
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent="Placement can vary." position="right">
|
||||
<span className="rounded-md bg-background-default px-3 py-1 text-xs text-text-secondary">
|
||||
Right tooltip
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-xs tracking-[0.18em] text-text-tertiary uppercase">Click tooltips</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Tooltip popupContent="Click again to close." triggerMethod="click" position="bottom-start">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
Click trigger
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent="Decoration disabled" triggerMethod="click" noDecoration>
|
||||
<span className="rounded-md border border-dashed border-divider-regular px-3 py-1 text-xs text-text-secondary">
|
||||
Plain content
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<typeof TooltipGrid>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
@ -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<TooltipProps> = ({
|
||||
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<ReturnType<typeof setTimeout> | 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 = (
|
||||
<div data-testid={triggerTestId} className={triggerClassName || 'h-3.5 w-3.5 shrink-0 p-px'}>
|
||||
<RiQuestionLine className="h-full w-full text-text-quaternary hover:text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
const triggerContent = children || fallbackTrigger
|
||||
const childElement = React.isValidElement<React.HTMLAttributes<HTMLElement>>(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<HTMLElement>) => {
|
||||
childProps.onMouseEnter?.(event)
|
||||
handleTriggerMouseEnter()
|
||||
},
|
||||
onMouseLeave: (event: React.MouseEvent<HTMLElement>) => {
|
||||
childProps.onMouseLeave?.(event)
|
||||
handleTriggerMouseLeave()
|
||||
},
|
||||
})
|
||||
}
|
||||
const effectiveOpen = !disabled && open
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={effectiveOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
{asChild
|
||||
? (
|
||||
<PopoverTrigger
|
||||
nativeButton={nativeButton}
|
||||
disabled={disabled}
|
||||
render={renderAsChildTrigger()}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
disabled={disabled}
|
||||
render={(
|
||||
<div
|
||||
className={triggerClassName}
|
||||
onMouseEnter={handleTriggerMouseEnter}
|
||||
onMouseLeave={handleTriggerMouseLeave}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{triggerContent}
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
{effectiveOpen && !!popupContent && (
|
||||
<PopoverContent
|
||||
placement={position}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={portalContentClassName}
|
||||
popupClassName={cn(
|
||||
noDecoration
|
||||
? 'border-0 bg-transparent p-0 shadow-none'
|
||||
: 'relative max-w-[300px] rounded-md border-0 bg-components-panel-bg px-3 py-2 text-left system-xs-regular wrap-break-word text-text-tertiary shadow-lg',
|
||||
popupClassName,
|
||||
)}
|
||||
popupProps={{
|
||||
onMouseEnter: handlePopupMouseEnter,
|
||||
onMouseLeave: handlePopupMouseLeave,
|
||||
}}
|
||||
>
|
||||
{popupContent}
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Tooltip)
|
||||
Loading…
Reference in New Issue
Block a user