mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
refactor(ui): migrate Tooltip from HeroUI to shadcn/Radix UI (#13563)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
This commit is contained in:
5
.changeset/tall-deserts-remain.md
Normal file
5
.changeset/tall-deserts-remain.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@cherrystudio/ui": patch
|
||||
---
|
||||
|
||||
implement tooltip
|
||||
@@ -51,7 +51,6 @@
|
||||
},
|
||||
"homepage": "https://github.com/CherryHQ/cherry-studio#readme",
|
||||
"peerDependencies": {
|
||||
"@heroui/react": "^2.8.4",
|
||||
"framer-motion": "^11.0.0 || ^12.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -87,7 +86,6 @@
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@heroui/react": "^2.8.4",
|
||||
"@neplex/vectorizer": "^0.0.5",
|
||||
"@storybook/addon-docs": "^10.0.5",
|
||||
"@storybook/addon-themes": "^10.0.5",
|
||||
|
||||
@@ -12,7 +12,7 @@ interface ImageToolButtonProps {
|
||||
|
||||
const ImageToolButton = ({ tooltip, icon, onPress }: ImageToolButtonProps) => {
|
||||
return (
|
||||
<Tooltip content={tooltip} delay={500} closeDelay={0}>
|
||||
<Tooltip content={tooltip} delay={500}>
|
||||
<Button size="icon" className="rounded-full" onClick={onPress} aria-label={tooltip}>
|
||||
{icon}
|
||||
</Button>
|
||||
|
||||
@@ -11,7 +11,15 @@ export { ErrorBoundary } from './primitives/ErrorBoundary'
|
||||
export { default as IndicatorLight } from './primitives/indicatorLight'
|
||||
export { default as Spinner } from './primitives/spinner'
|
||||
export { DescriptionSwitch, Switch } from './primitives/switch'
|
||||
export { Tooltip, type TooltipProps } from './primitives/tooltip'
|
||||
export {
|
||||
NormalTooltip,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
type TooltipProps,
|
||||
TooltipProvider,
|
||||
TooltipRoot,
|
||||
TooltipTrigger
|
||||
} from './primitives/tooltip'
|
||||
|
||||
// Composite Components
|
||||
export { ConfirmDialog, type ConfirmDialogProps } from './composites/ConfirmDialog'
|
||||
|
||||
172
packages/ui/src/components/primitives/__tests__/tooltip.test.tsx
Normal file
172
packages/ui/src/components/primitives/__tests__/tooltip.test.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
// @vitest-environment jsdom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Tooltip } from '../tooltip'
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Tooltip', () => {
|
||||
describe('fallback rendering (no tooltip wrapper)', () => {
|
||||
it('renders a plain div when content is undefined', () => {
|
||||
const { container } = render(
|
||||
<Tooltip>
|
||||
<span>No tooltip</span>
|
||||
</Tooltip>
|
||||
)
|
||||
expect(screen.getByText('No tooltip')).toBeInTheDocument()
|
||||
const wrapper = container.firstElementChild as HTMLElement
|
||||
expect(wrapper.tagName).toBe('DIV')
|
||||
expect(wrapper.getAttribute('data-state')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a plain div when content is empty string', () => {
|
||||
const { container } = render(
|
||||
<Tooltip content="">
|
||||
<span>Empty</span>
|
||||
</Tooltip>
|
||||
)
|
||||
const wrapper = container.firstElementChild as HTMLElement
|
||||
expect(wrapper.getAttribute('data-state')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a plain div when isDisabled is true', () => {
|
||||
const { container } = render(
|
||||
<Tooltip content="tip" isDisabled>
|
||||
<span>Disabled</span>
|
||||
</Tooltip>
|
||||
)
|
||||
const wrapper = container.firstElementChild as HTMLElement
|
||||
expect(wrapper.tagName).toBe('DIV')
|
||||
expect(wrapper.getAttribute('data-state')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Radix trigger rendering', () => {
|
||||
it('wraps children with Radix trigger when content is provided', () => {
|
||||
const { container } = render(
|
||||
<Tooltip content="tip">
|
||||
<button type="button">Trigger</button>
|
||||
</Tooltip>
|
||||
)
|
||||
const trigger = container.querySelector('[data-state]')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
expect(screen.getByText('Trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses title as fallback when content is not provided', () => {
|
||||
const { container } = render(
|
||||
<Tooltip title="title-tip">
|
||||
<button type="button">Trigger</button>
|
||||
</Tooltip>
|
||||
)
|
||||
const trigger = container.querySelector('[data-state]')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('treats content=undefined + title=undefined as fallback', () => {
|
||||
const { container } = render(
|
||||
<Tooltip content={undefined} title={undefined}>
|
||||
<span>Child</span>
|
||||
</Tooltip>
|
||||
)
|
||||
expect(container.querySelector('[data-state]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('classNames', () => {
|
||||
it('applies classNames.placeholder to the trigger wrapper', () => {
|
||||
const { container } = render(
|
||||
<Tooltip content="tip" classNames={{ placeholder: 'custom-trigger' }}>
|
||||
<button type="button">Trigger</button>
|
||||
</Tooltip>
|
||||
)
|
||||
expect(container.querySelector('.custom-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies classNames.placeholder to fallback div when disabled', () => {
|
||||
const { container } = render(
|
||||
<Tooltip content="tip" isDisabled classNames={{ placeholder: 'custom-ph' }}>
|
||||
<span>Child</span>
|
||||
</Tooltip>
|
||||
)
|
||||
expect(container.querySelector('.custom-ph')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies classNames.placeholder to fallback div when no content', () => {
|
||||
const { container } = render(
|
||||
<Tooltip classNames={{ placeholder: 'ph-class' }}>
|
||||
<span>Child</span>
|
||||
</Tooltip>
|
||||
)
|
||||
expect(container.querySelector('.ph-class')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onClick', () => {
|
||||
it('fires onClick on the trigger wrapper', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<Tooltip content="tip" onClick={handleClick}>
|
||||
<button type="button">Click me</button>
|
||||
</Tooltip>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Click me'))
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fires onClick on disabled tooltip wrapper', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<Tooltip content="tip" isDisabled onClick={handleClick}>
|
||||
<button type="button">Click me</button>
|
||||
</Tooltip>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Click me'))
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fires onClick on no-content fallback wrapper', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<Tooltip onClick={handleClick}>
|
||||
<button type="button">Click me</button>
|
||||
</Tooltip>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Click me'))
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('controlled mode', () => {
|
||||
it('renders tooltip content in DOM when isOpen is true', () => {
|
||||
render(
|
||||
<Tooltip content="forced open" isOpen={true}>
|
||||
<button type="button">Trigger</button>
|
||||
</Tooltip>
|
||||
)
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render tooltip content when isOpen is false', () => {
|
||||
render(
|
||||
<Tooltip content="forced closed" isOpen={false}>
|
||||
<button type="button">Trigger</button>
|
||||
</Tooltip>
|
||||
)
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,34 +1,189 @@
|
||||
import type { TooltipProps as HeroUITooltipProps } from '@heroui/react'
|
||||
import { cn, Tooltip as HeroUITooltip } from '@heroui/react'
|
||||
import {
|
||||
Arrow as RadixArrow,
|
||||
Content as RadixContent,
|
||||
Portal as RadixPortal,
|
||||
Provider as RadixProvider,
|
||||
Root as RadixRoot,
|
||||
Trigger as RadixTrigger
|
||||
} from '@radix-ui/react-tooltip'
|
||||
import * as React from 'react'
|
||||
|
||||
export interface TooltipProps extends HeroUITooltipProps {}
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
/**
|
||||
* Tooltip wrapper that applies consistent styling and arrow display.
|
||||
* Differences from raw HeroUI Tooltip:
|
||||
* 1. Defaults showArrow={true}
|
||||
* 2. Merges a default max-w-60 class into the content slot, capping width at 240px.
|
||||
* All other HeroUI Tooltip props/behaviors remain unchanged.
|
||||
*
|
||||
* @see https://www.heroui.com/docs/components/tooltip
|
||||
*/
|
||||
export const Tooltip = ({
|
||||
children,
|
||||
classNames,
|
||||
showArrow,
|
||||
...rest
|
||||
}: Omit<TooltipProps, 'classNames'> & {
|
||||
classNames?: TooltipProps['classNames'] & { placeholder?: string }
|
||||
}) => {
|
||||
type Side = 'top' | 'bottom' | 'left' | 'right'
|
||||
type Align = 'start' | 'center' | 'end'
|
||||
|
||||
function parsePlacement(placement?: string): { side: Side; align: Align } {
|
||||
const mapping: Record<string, { side: Side; align: Align }> = {
|
||||
top: { side: 'top', align: 'center' },
|
||||
'top-start': { side: 'top', align: 'start' },
|
||||
'top-end': { side: 'top', align: 'end' },
|
||||
bottom: { side: 'bottom', align: 'center' },
|
||||
'bottom-start': { side: 'bottom', align: 'start' },
|
||||
'bottom-end': { side: 'bottom', align: 'end' },
|
||||
bottomRight: { side: 'bottom', align: 'end' },
|
||||
left: { side: 'left', align: 'center' },
|
||||
'left-start': { side: 'left', align: 'start' },
|
||||
'left-end': { side: 'left', align: 'end' },
|
||||
right: { side: 'right', align: 'center' },
|
||||
'right-start': { side: 'right', align: 'start' },
|
||||
'right-end': { side: 'right', align: 'end' }
|
||||
}
|
||||
return mapping[placement ?? 'top'] ?? { side: 'top', align: 'center' }
|
||||
}
|
||||
|
||||
// --- Compound component exports (for advanced usage) ---
|
||||
|
||||
export type TooltipProviderProps = React.ComponentProps<typeof RadixProvider>
|
||||
export type TooltipRootProps = React.ComponentProps<typeof RadixRoot>
|
||||
export type TooltipTriggerProps = React.ComponentProps<typeof RadixTrigger>
|
||||
export type TooltipContentProps = React.ComponentProps<typeof RadixContent>
|
||||
|
||||
function TooltipProvider({ delayDuration = 0, ...props }: TooltipProviderProps) {
|
||||
return <RadixProvider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
|
||||
}
|
||||
|
||||
function TooltipRoot({ delayDuration = 0, ...props }: TooltipRootProps) {
|
||||
return (
|
||||
<HeroUITooltip
|
||||
classNames={{
|
||||
...classNames,
|
||||
content: cn('max-w-60', classNames?.content)
|
||||
}}
|
||||
showArrow={showArrow ?? true}
|
||||
{...rest}>
|
||||
<div className={cn('relative z-10 inline-block', classNames?.placeholder)}>{children}</div>
|
||||
</HeroUITooltip>
|
||||
<TooltipProvider delayDuration={delayDuration}>
|
||||
<RadixRoot data-slot="tooltip" delayDuration={delayDuration} {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipTriggerProps) {
|
||||
return <RadixTrigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
const contentStyles =
|
||||
'z-50 max-w-60 rounded-3xs border border-border bg-background px-4 py-3 text-sm leading-4 text-foreground shadow-[0px_6px_12px_0px_rgba(0,0,0,0.2)] animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'
|
||||
|
||||
function TooltipContent({ className, sideOffset = 4, children, ...props }: TooltipContentProps) {
|
||||
return (
|
||||
<RadixPortal>
|
||||
<RadixContent
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(contentStyles, className)}
|
||||
{...props}>
|
||||
{children}
|
||||
<RadixArrow className="fill-background [&>path:first-child]:fill-border" />
|
||||
</RadixContent>
|
||||
</RadixPortal>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Backward-compatible flat API (drop-in replacement for HeroUI Tooltip) ---
|
||||
|
||||
export interface TooltipProps {
|
||||
children?: React.ReactNode
|
||||
content?: React.ReactNode
|
||||
title?: React.ReactNode
|
||||
placement?: string
|
||||
delay?: number
|
||||
showArrow?: boolean
|
||||
classNames?: {
|
||||
content?: string
|
||||
placeholder?: string
|
||||
}
|
||||
className?: string
|
||||
isDisabled?: boolean
|
||||
isOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>
|
||||
}
|
||||
|
||||
export const Tooltip = ({
|
||||
children,
|
||||
content,
|
||||
title,
|
||||
placement,
|
||||
delay = 0,
|
||||
showArrow = true,
|
||||
classNames,
|
||||
className,
|
||||
isDisabled,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onClick
|
||||
}: TooltipProps) => {
|
||||
const tooltipContent = content ?? title
|
||||
if (!tooltipContent || isDisabled) {
|
||||
return (
|
||||
<div className={cn('relative z-10 inline-block', classNames?.placeholder)} onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { side, align } = parsePlacement(placement)
|
||||
|
||||
const controlledProps: Partial<React.ComponentProps<typeof RadixRoot>> = {}
|
||||
if (isOpen != null) {
|
||||
controlledProps.open = isOpen
|
||||
controlledProps.onOpenChange = onOpenChange
|
||||
} else if (onOpenChange) {
|
||||
controlledProps.onOpenChange = onOpenChange
|
||||
}
|
||||
|
||||
return (
|
||||
<RadixProvider delayDuration={delay}>
|
||||
<RadixRoot delayDuration={delay} {...controlledProps}>
|
||||
<RadixTrigger asChild>
|
||||
<div className={cn('relative z-10 inline-block', classNames?.placeholder)} onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
</RadixTrigger>
|
||||
<RadixPortal>
|
||||
<RadixContent
|
||||
data-slot="tooltip-content"
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={4}
|
||||
className={cn(contentStyles, classNames?.content, className)}>
|
||||
{tooltipContent}
|
||||
{showArrow && <RadixArrow className="fill-background [&>path:first-child]:fill-border" />}
|
||||
</RadixContent>
|
||||
</RadixPortal>
|
||||
</RadixRoot>
|
||||
</RadixProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// --- NormalTooltip (convenience wrapper using compound components) ---
|
||||
|
||||
interface NormalTooltipProps extends TooltipRootProps {
|
||||
content: React.ReactNode
|
||||
side?: TooltipContentProps['side']
|
||||
align?: TooltipContentProps['align']
|
||||
sideOffset?: TooltipContentProps['sideOffset']
|
||||
className?: string
|
||||
asChild?: boolean
|
||||
triggerProps?: Omit<TooltipTriggerProps, 'children'>
|
||||
contentProps?: TooltipContentProps
|
||||
}
|
||||
|
||||
const NormalTooltip = ({
|
||||
children,
|
||||
content,
|
||||
side,
|
||||
align,
|
||||
sideOffset,
|
||||
asChild = true,
|
||||
triggerProps,
|
||||
contentProps,
|
||||
...tooltipProps
|
||||
}: NormalTooltipProps) => {
|
||||
return (
|
||||
<TooltipRoot {...tooltipProps}>
|
||||
<TooltipTrigger asChild={asChild} {...triggerProps}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} align={align} sideOffset={sideOffset} {...contentProps}>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export { NormalTooltip, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger }
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { cn } from '@cherrystudio/ui/lib/utils'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import * as React from 'react'
|
||||
|
||||
export type TooltipProps = React.ComponentProps<typeof TooltipPrimitive.Root>
|
||||
export type TooltipTriggerProps = React.ComponentProps<typeof TooltipPrimitive.Trigger>
|
||||
export type TooltipContentProps = React.ComponentProps<typeof TooltipPrimitive.Content>
|
||||
|
||||
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
// eslint-disable-next-line
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
|
||||
}
|
||||
|
||||
function Tooltip({ delayDuration = 0, ...props }: TooltipProps) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={delayDuration}>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" delayDuration={delayDuration} {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipTriggerProps) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({ className, sideOffset = 0, children, ...props }: TooltipContentProps) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
interface NormalTooltipProps extends TooltipProps {
|
||||
content: React.ReactNode
|
||||
side?: TooltipContentProps['side']
|
||||
align?: TooltipContentProps['align']
|
||||
sideOffset?: TooltipContentProps['sideOffset']
|
||||
className?: string
|
||||
asChild?: boolean
|
||||
triggerProps?: Omit<TooltipTriggerProps, 'children'>
|
||||
contentProps?: TooltipContentProps
|
||||
}
|
||||
|
||||
const NormalTooltip = ({
|
||||
children,
|
||||
content,
|
||||
side,
|
||||
align,
|
||||
sideOffset,
|
||||
asChild = true,
|
||||
triggerProps,
|
||||
contentProps,
|
||||
...tooltipProps
|
||||
}: NormalTooltipProps) => {
|
||||
return (
|
||||
<Tooltip {...tooltipProps}>
|
||||
<TooltipTrigger asChild={asChild} {...triggerProps}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} align={align} sideOffset={sideOffset} {...contentProps}>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export { NormalTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
@@ -12,12 +12,9 @@ export default defineConfig({
|
||||
clean: true,
|
||||
dts: true,
|
||||
tsconfig: 'tsconfig.json',
|
||||
// 将 HeroUI、Tailwind 和其他 peer dependencies 标记为外部依赖
|
||||
external: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'@heroui/react',
|
||||
'@heroui/theme',
|
||||
'framer-motion',
|
||||
'tailwindcss',
|
||||
// 保留 styled-components 作为外部依赖(迁移期间)
|
||||
|
||||
3326
pnpm-lock.yaml
generated
3326
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ const CodePanel = memo<CodePanelProps>(({ codeEditorRef, html, onSave, saved, on
|
||||
}}
|
||||
/>
|
||||
<ToolbarWrapper>
|
||||
<Tooltip content={saveLabel} closeDelay={0}>
|
||||
<Tooltip content={saveLabel}>
|
||||
<ToolbarButton size="icon" onClick={onClickSave}>
|
||||
{saved ? (
|
||||
<Check size={16} color="var(--color-status-success)" />
|
||||
@@ -206,7 +206,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
}
|
||||
]
|
||||
}}>
|
||||
<Tooltip content={t('html_artifacts.capture.label')} closeDelay={0}>
|
||||
<Tooltip content={t('html_artifacts.capture.label')}>
|
||||
<Button variant="ghost" size="icon" className="nodrag">
|
||||
<Camera size={16} />
|
||||
</Button>
|
||||
|
||||
@@ -22,7 +22,7 @@ const CodeToolButton = ({ tool }: CodeToolButtonProps) => {
|
||||
|
||||
const mainTool = useMemo(
|
||||
() => (
|
||||
<Tooltip key={tool.id} content={tool.tooltip} delay={500} closeDelay={0}>
|
||||
<Tooltip key={tool.id} content={tool.tooltip} delay={500}>
|
||||
<ToolWrapper
|
||||
onClick={tool.onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -94,7 +94,7 @@ const CollapsibleSearchBar = ({
|
||||
}}
|
||||
style={{ cursor: 'pointer', display: 'flex' }}
|
||||
onClick={() => setSearchVisible(true)}>
|
||||
<Tooltip content={tooltip} delay={500} closeDelay={0}>
|
||||
<Tooltip content={tooltip} delay={500}>
|
||||
{icon}
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
|
||||
@@ -140,13 +140,13 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
|
||||
|
||||
<Flex className="items-center gap-0">
|
||||
{showHealthCheck && (
|
||||
<Tooltip content={t('settings.provider.check')} closeDelay={0}>
|
||||
<Tooltip content={t('settings.provider.check')}>
|
||||
<Button variant="ghost" onClick={onCheck} disabled={disabled} size="icon">
|
||||
<StreamlineGoodHealthAndWellBeing size={18} isActive={keyStatus.checking} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={t('common.edit')} closeDelay={0}>
|
||||
<Tooltip content={t('common.edit')}>
|
||||
<Button variant="ghost" onClick={handleEdit} disabled={disabled} size="icon">
|
||||
<EditIcon size={16} />
|
||||
</Button>
|
||||
@@ -158,7 +158,7 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ color: 'danger' }}>
|
||||
<Tooltip content={t('common.delete')} closeDelay={0}>
|
||||
<Tooltip content={t('common.delete')}>
|
||||
<Button variant="ghost" disabled={disabled} size="icon">
|
||||
<Minus size={16} />
|
||||
</Button>
|
||||
|
||||
@@ -142,7 +142,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, show
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ color: 'danger' }}>
|
||||
<Tooltip content={t('settings.provider.remove_invalid_keys')} closeDelay={0}>
|
||||
<Tooltip content={t('settings.provider.remove_invalid_keys')}>
|
||||
<Button variant="ghost" disabled={isChecking || !!pendingNewKey} size="icon">
|
||||
<DeleteIcon size={16} className="lucide-custom" />
|
||||
</Button>
|
||||
@@ -150,7 +150,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, show
|
||||
</Popconfirm>
|
||||
|
||||
{/* 批量检查 */}
|
||||
<Tooltip content={t('settings.provider.check_all_keys')} closeDelay={0}>
|
||||
<Tooltip content={t('settings.provider.check_all_keys')}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={checkAllKeysConnectivity}
|
||||
|
||||
@@ -373,7 +373,7 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
|
||||
{option.count}
|
||||
</CustomTag>
|
||||
<span>{option.label}</span>
|
||||
<HelpTooltip content={option.description} closeDelay={0} />
|
||||
<HelpTooltip content={option.description} />
|
||||
</Flex>
|
||||
{selectedTypes.includes(option.type) && <Check size={16} color={TAG_COLORS.SELECTED} />}
|
||||
</ContentTypeItem>
|
||||
|
||||
@@ -226,7 +226,7 @@ const SelectModelPopupView: React.FC<Props> = ({
|
||||
type: 'group',
|
||||
name: getFancyProviderName(provider),
|
||||
actions: canNavigateToSettings && (
|
||||
<Tooltip content={t('navigate.provider_settings')} delay={500} closeDelay={0}>
|
||||
<Tooltip content={t('navigate.provider_settings')} delay={500}>
|
||||
<Settings2
|
||||
size={12}
|
||||
color="var(--color-text)"
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ImageToolButtonProps {
|
||||
|
||||
const ImageToolButton = ({ tooltip, icon, onClick }: ImageToolButtonProps) => {
|
||||
return (
|
||||
<Tooltip content={tooltip} delay={500} closeDelay={0}>
|
||||
<Tooltip content={tooltip} delay={500}>
|
||||
<Button className="rounded-full" onClick={onClick} size="icon" aria-label={tooltip}>
|
||||
{icon}
|
||||
</Button>
|
||||
|
||||
@@ -52,7 +52,7 @@ const ProviderLogoPicker: FC<Props> = ({ onProviderClick }) => {
|
||||
</SearchContainer>
|
||||
<LogoGrid>
|
||||
{filteredProviders.map(({ id, name, icon }) => (
|
||||
<Tooltip key={id} content={name} closeDelay={0}>
|
||||
<Tooltip key={id} content={name}>
|
||||
<LogoItem onClick={(e) => handleProviderClick(e, id)}>
|
||||
<ProviderAvatarPrimitive
|
||||
providerId={id}
|
||||
|
||||
@@ -64,9 +64,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
|
||||
}, [isLoading])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t('chat.input.translate', { target_language: getLanguageByLangcode(targetLanguage).label() })}
|
||||
closeDelay={0}>
|
||||
<Tooltip content={t('chat.input.translate', { target_language: getLanguageByLangcode(targetLanguage).label() })}>
|
||||
<Button
|
||||
onClick={handleTranslate}
|
||||
disabled={disabled || isTranslating}
|
||||
|
||||
@@ -59,7 +59,6 @@ const SessionItem = ({ session, agentId, channelType, onDelete, onPress }: Sessi
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
delay={700}
|
||||
closeDelay={0}
|
||||
content={
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
|
||||
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
|
||||
|
||||
@@ -155,7 +155,7 @@ const AttachmentButton: FC<Props> = ({ quickPanel, couldAddImageFile, extensions
|
||||
const ariaLabel = couldAddImageFile ? t('chat.input.upload.image_or_document') : t('chat.input.upload.document')
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" content={ariaLabel} closeDelay={0}>
|
||||
<Tooltip placement="top" content={ariaLabel}>
|
||||
<ActionIconButton
|
||||
onClick={openFileSelectDialog}
|
||||
active={files.length > 0}
|
||||
|
||||
@@ -20,7 +20,7 @@ const GenerateImageButton: FC<Props> = ({ model, assistant, onEnableGenerateImag
|
||||
: t('chat.input.generate_image_not_supported')
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" content={ariaLabel} closeDelay={0}>
|
||||
<Tooltip placement="top" content={ariaLabel}>
|
||||
<ActionIconButton
|
||||
onClick={onEnableGenerateImage}
|
||||
active={assistant.enableGenerateImage}
|
||||
|
||||
@@ -112,7 +112,7 @@ const KnowledgeBaseButton: FC<Props> = ({ quickPanel, selectedBases, onSelect, d
|
||||
}, [openQuickPanel, quickPanel, t])
|
||||
|
||||
return (
|
||||
<Tooltip content={t('chat.input.knowledge_base')} closeDelay={0}>
|
||||
<Tooltip content={t('chat.input.knowledge_base')}>
|
||||
<ActionIconButton
|
||||
onClick={handleOpenQuickPanel}
|
||||
active={selectedBases && selectedBases.length > 0}
|
||||
|
||||
@@ -573,7 +573,7 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={t('settings.mcp.title')} closeDelay={0}>
|
||||
<Tooltip content={t('settings.mcp.title')}>
|
||||
<ActionIconButton
|
||||
onClick={handleOpenQuickPanel}
|
||||
active={isActive}
|
||||
|
||||
@@ -15,7 +15,7 @@ const NewContextButton: FC<Props> = ({ onNewContext }) => {
|
||||
useShortcut('toggle_new_context', onNewContext)
|
||||
|
||||
return (
|
||||
<Tooltip content={t('chat.input.new.context', { Command: newContextShortcut })} closeDelay={0}>
|
||||
<Tooltip content={t('chat.input.new.context', { Command: newContextShortcut })}>
|
||||
<ActionIconButton
|
||||
onClick={onNewContext}
|
||||
aria-label={t('chat.input.new.context', { Command: newContextShortcut })}
|
||||
|
||||
@@ -250,7 +250,7 @@ const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assista
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip content={t('settings.quickPhrase.title')} closeDelay={0}>
|
||||
<Tooltip content={t('settings.quickPhrase.title')}>
|
||||
<ActionIconButton
|
||||
onClick={handleOpenQuickPanel}
|
||||
aria-label={t('settings.quickPhrase.title')}
|
||||
|
||||
@@ -219,7 +219,7 @@ const ThinkingButton: FC<Props> = ({
|
||||
: t('common.close')
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" content={ariaLabel} closeDelay={0}>
|
||||
<Tooltip placement="top" content={ariaLabel}>
|
||||
<ActionIconButton
|
||||
onClick={handleOpenQuickPanel}
|
||||
active={isFixedReasoning || currentReasoningEffort !== 'none'}
|
||||
|
||||
@@ -121,8 +121,8 @@ vi.mock('@renderer/components/Buttons', () => ({
|
||||
|
||||
// Mock @cherrystudio/ui Tooltip
|
||||
vi.mock('@cherrystudio/ui', () => ({
|
||||
Tooltip: ({ content, children, placement, closeDelay }: any) => (
|
||||
<div data-testid="tooltip" data-title={content} data-placement={placement} data-close-delay={closeDelay}>
|
||||
Tooltip: ({ content, children, placement }: any) => (
|
||||
<div data-testid="tooltip" data-title={content} data-placement={placement}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -167,8 +167,7 @@ const CustomNode: FC<{ data: any }> = ({ data }) => {
|
||||
</TooltipContent>
|
||||
}
|
||||
classNames={{ content: 'bg-[#000000d8] text-gray-200 text-sm' }}
|
||||
delay={300}
|
||||
closeDelay={100}>
|
||||
delay={300}>
|
||||
<CustomNodeContainer
|
||||
style={{
|
||||
borderColor,
|
||||
|
||||
@@ -41,7 +41,7 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
<Tooltip key={message.id} content={modelTip} delay={500} closeDelay={0}>
|
||||
<Tooltip key={message.id} content={modelTip} delay={500}>
|
||||
<AvatarWrapper
|
||||
className="avatar-wrapper"
|
||||
$isSelected={message.id === selectMessageId}
|
||||
@@ -73,8 +73,7 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
|
||||
? t('message.message.multi_model_style.fold.expand')
|
||||
: t('message.message.multi_model_style.fold.compress')
|
||||
}
|
||||
delay={500}
|
||||
closeDelay={0}>
|
||||
delay={500}>
|
||||
<DisplayModeToggle
|
||||
displayMode={foldDisplayMode}
|
||||
onClick={() => setFoldDisplayMode(isCompact ? 'expanded' : 'compact')}>
|
||||
@@ -90,7 +89,7 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
|
||||
const isSelected = message.id === selectMessageId
|
||||
|
||||
return (
|
||||
<Tooltip key={message.id} content={modelTip} delay={500} closeDelay={0}>
|
||||
<Tooltip key={message.id} content={modelTip} delay={500}>
|
||||
{(() => {
|
||||
const Icon = getModelLogo(message.model)
|
||||
return Icon ? (
|
||||
|
||||
@@ -138,7 +138,7 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
|
||||
<ToolName className="items-center gap-1">
|
||||
{tool.serverName} : {tool.name}
|
||||
{isToolAutoApproved(tool) && (
|
||||
<Tooltip content={t('message.tools.autoApproveEnabled')} closeDelay={0}>
|
||||
<Tooltip content={t('message.tools.autoApproveEnabled')}>
|
||||
<ShieldCheck size={14} color="var(--status-color-success)" />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -102,7 +102,7 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
gap: 12
|
||||
}}>
|
||||
{tmpViewMode === 'preview' && (
|
||||
<Tooltip placement="top" content={t('notes.spell_check_tooltip')} closeDelay={0}>
|
||||
<Tooltip placement="top" content={t('notes.spell_check_tooltip')}>
|
||||
<ActionIconButton
|
||||
active={enableSpellCheck}
|
||||
onClick={() => {
|
||||
|
||||
@@ -153,7 +153,7 @@ const AssistantSettings: FC = () => {
|
||||
marginTop: 0
|
||||
}}>
|
||||
{t('settings.assistant.model_params')}
|
||||
<Tooltip content={t('common.reset')} closeDelay={0}>
|
||||
<Tooltip content={t('common.reset')}>
|
||||
<Button variant="ghost" onClick={onReset} size="icon">
|
||||
<ResetIcon size={16} />
|
||||
</Button>
|
||||
|
||||
@@ -120,8 +120,7 @@ const ManageModelsList: React.FC<ManageModelsListProps> = ({
|
||||
isAllInProvider
|
||||
? t('settings.models.manage.remove_whole_group')
|
||||
: t('settings.models.manage.add_whole_group')
|
||||
}
|
||||
closeDelay={0}>
|
||||
}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -240,7 +240,7 @@ const PopupContainer: React.FC<Props> = ({ providerId, resolve }) => {
|
||||
{isAllFilteredInProvider ? <ListMinus size={18} /> : <ListPlus size={18} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('settings.models.manage.refetch_list')} closeDelay={0}>
|
||||
<Tooltip content={t('settings.models.manage.refetch_list')}>
|
||||
<Button variant="ghost" size="icon-lg" onClick={() => loadModels(provider)} disabled={loadingModels}>
|
||||
<RefreshCcw size={16} />
|
||||
</Button>
|
||||
|
||||
@@ -58,7 +58,7 @@ const ModelListGroup: React.FC<ModelListGroupProps> = ({
|
||||
</Flex>
|
||||
}
|
||||
extra={
|
||||
<Tooltip content={t('settings.models.manage.remove_whole_group')} closeDelay={0}>
|
||||
<Tooltip content={t('settings.models.manage.remove_whole_group')}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="toolbar-item"
|
||||
|
||||
@@ -98,12 +98,12 @@ const ModelListItem: React.FC<ModelListItemProps> = ({
|
||||
onErrorClick={handleErrorClick}
|
||||
/>
|
||||
<RowFlex className="items-center">
|
||||
<Tooltip content={t('models.edit')} closeDelay={0}>
|
||||
<Tooltip content={t('models.edit')}>
|
||||
<Button variant="ghost" onClick={handleEdit} disabled={disabled} size="icon">
|
||||
<Bolt size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('settings.models.manage.remove_model')} closeDelay={0}>
|
||||
<Tooltip content={t('settings.models.manage.remove_model')}>
|
||||
<Button variant="ghost" onClick={handleRemove} disabled={disabled} size="icon">
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user