feat(dify-ui): add PreviewCard primitive (#35434)

Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-04-21 12:05:22 +08:00 committed by GitHub
parent d583b1b835
commit c2a5962023
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1151 additions and 661 deletions

4
.gitignore vendored
View File

@ -237,6 +237,10 @@ scripts/stress-test/reports/
.playwright-mcp/
.serena/
# vitest browser mode attachments (failure screenshots, traces, etc.)
.vitest-attachments/
**/__screenshots__/
# settings
*.local.json
*.local.md

View File

@ -1931,11 +1931,6 @@
"count": 2
}
},
"web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/last-run-block/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 3
@ -4087,15 +4082,7 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/blocks.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/featured-tools.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 2
},
@ -4104,9 +4091,6 @@
}
},
"web/app/components/workflow/block-selector/featured-triggers.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 2
},
@ -4139,11 +4123,6 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/start-blocks.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tabs.tsx": {
"no-restricted-imports": {
"count": 1
@ -4154,11 +4133,6 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/tool/action-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -4170,9 +4144,6 @@
}
},
"web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}

View File

@ -11,6 +11,27 @@ Shared design tokens, the `cn()` utility, a Tailwind CSS preset, and headless pr
- Props pattern: `Omit<BaseXxx.Root.Props, 'className' | ...> & VariantProps<typeof xxxVariants> & { /* custom */ }`.
- When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath.
## Overlay Primitive Selection: Tooltip vs PreviewCard vs Popover
Pick by the **trigger's purpose** and **a11y reach**, not visual richness.
| Primitive | Opens on | Trigger's purpose | Content | Reachable on touch / SR? |
| ------------- | --------------------- | -------------------------- | ------------------------- | ------------------------ |
| `Tooltip` | hover / focus | has its own action | short plain-text label | ❌ (label only) |
| `PreviewCard` | hover / focus | has a primary click target | supplementary preview | ❌ (via click target) |
| `Popover` | click / tap (+ hover) | **to open the popup** | anything, incl. long text | ✅ |
Base UI decision rule ([docs]):
> _"If the trigger's purpose is to open the popup itself, it's a popover.
> If the trigger's purpose is unrelated to opening the popup, it's a tooltip."_
Apply this first, then narrow:
- `Tooltip` — ephemeral visual label. Trigger must already carry its own `aria-label` / visible text; tooltip mirrors it for sighted mouse/keyboard users. No interactive UI, no multi-line prose. Not dwell-able.
- `PreviewCard` — hover-revealed rich supplementary preview anchored to a trigger whose click goes somewhere (link, selectable row, jumpable chip). **Hard contract:** the popup MUST NOT contain information or actions unreachable from the trigger's click destination — touch and SR users can't open it. If the info is unique to the popup, switch to `Popover` (click or `openOnHover`) or move it to the click destination. Do not hand-roll "hover to open" on top of `Popover` to evade this split.
- `Popover` — any popup with its own interactions, or any "infotip" (`?` / `(i)` glyph whose sole purpose is to reveal help text). Pass `openOnHover` on `PopoverTrigger` for the infotip case — unlike `Tooltip` / `PreviewCard`, this stays accessible to touch and SR users because the popover still opens on tap and focus.
## Border Radius: Figma Token → Tailwind Class Mapping
The Figma design system uses `--radius/*` tokens whose scale is **offset by one step** from Tailwind CSS v4 defaults. When translating Figma specs to code, always use this mapping — never use `radius-*` as a CSS class, and never extend `borderRadius` in the preset.
@ -34,3 +55,5 @@ The Figma design system uses `--radius/*` tokens whose scale is **offset by one
- **Do not** use `radius-*` as CSS class names. The old `@utility radius-*` definitions have been removed.
- When the Figma MCP returns `rounded-[var(--radius/sm, 6px)]`, convert it to the standard Tailwind class from the table above (e.g. `rounded-md`).
- For values without a standard Tailwind equivalent (10px, 20px, 28px), use arbitrary values like `rounded-[10px]`.
[docs]: https://base-ui.com/react/components/tooltip#infotips

View File

@ -49,6 +49,10 @@
"types": "./src/popover/index.tsx",
"import": "./src/popover/index.tsx"
},
"./preview-card": {
"types": "./src/preview-card/index.tsx",
"import": "./src/preview-card/index.tsx"
},
"./scroll-area": {
"types": "./src/scroll-area/index.tsx",
"import": "./src/scroll-area/index.tsx"

View File

@ -20,7 +20,7 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.',
component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.\n\nPass `openOnHover` on `PopoverTrigger` when the popup should also reveal on hover (see the **Infotip** story). Unlike `Tooltip` and `PreviewCard`, hover on `Popover` still falls back to tap/focus, so touch and screen-reader users can reach the content.',
},
},
},
@ -101,6 +101,48 @@ export const WithActions: Story = {
),
}
export const Infotip: Story = {
parameters: {
docs: {
description: {
story: [
'The **infotip** pattern from [Base UI](https://base-ui.com/react/components/tooltip#infotips): an info glyph (`?`, `(i)`) whose sole purpose is to reveal explanatory text. Use `Popover` with `openOnHover` on the trigger — never `Tooltip`.',
'',
'Why not `Tooltip`? Tooltips are disabled on touch devices and not announced to screen readers; descriptive help text hidden in them is unreachable for those users. Why not `PreviewCard`? PreviewCard\'s a11y contract requires the trigger to already own a primary click destination, but an info glyph has no other purpose.',
'',
'Base UI rule of thumb: *"If the trigger\'s purpose is to open the popup itself, it\'s a popover. If the trigger\'s purpose is unrelated to opening the popup, it\'s a tooltip."*',
'',
'Hover, tap, or focus the `?` icon to open. In the Dify app, reach for `@/app/components/base/infotip` (`<Infotip aria-label={...}>{helpText}</Infotip>`) which wraps this pattern with consistent delays (300/200), typography, and `aria-label` plumbing.',
].join('\n'),
},
},
},
render: () => (
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<span>Usage priority</span>
<Popover>
<PopoverTrigger
openOnHover
delay={300}
closeDelay={200}
aria-label="Set which resource to use first when running models."
render={(
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<PopoverContent
placement="top"
popupClassName="max-w-[300px] px-3 py-2 system-xs-regular text-text-tertiary"
>
Set which resource to use first when running models. The Trial quota will be used after the paid quota is exhausted.
</PopoverContent>
</Popover>
</div>
),
}
const PLACEMENTS: Placement[] = [
'top-start',
'top',

View File

@ -0,0 +1,127 @@
import { render } from 'vitest-browser-react'
import {
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '..'
const renderWithSafeViewport = (ui: import('react').ReactNode) => render(
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
{ui}
</div>,
)
describe('PreviewCardContent', () => {
describe('Placement', () => {
it('should use bottom placement and default offsets when placement props are not provided', async () => {
const screen = await renderWithSafeViewport(
<PreviewCard open>
<PreviewCardTrigger
render={<button type="button" aria-label="preview trigger">Open</button>}
/>
<PreviewCardContent
positionerProps={{ 'role': 'group', 'aria-label': 'default positioner' }}
popupProps={{ 'role': 'dialog', 'aria-label': 'default popup' }}
>
<span>Default content</span>
</PreviewCardContent>
</PreviewCard>,
)
await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-side', 'bottom')
await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-align', 'center')
await expect.element(screen.getByRole('dialog', { name: 'default popup' })).toHaveTextContent('Default content')
})
it('should apply parsed custom placement and custom offsets when placement props are provided', async () => {
const screen = await renderWithSafeViewport(
<PreviewCard open>
<PreviewCardTrigger
render={<button type="button" aria-label="preview trigger">Open</button>}
/>
<PreviewCardContent
placement="top-end"
sideOffset={14}
alignOffset={6}
positionerProps={{ 'role': 'group', 'aria-label': 'custom positioner' }}
popupProps={{ 'role': 'dialog', 'aria-label': 'custom popup' }}
>
<span>Custom placement content</span>
</PreviewCardContent>
</PreviewCard>,
)
await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-side', 'top')
await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-align', 'end')
await expect.element(screen.getByRole('dialog', { name: 'custom popup' })).toHaveTextContent('Custom placement content')
})
})
describe('Passthrough props', () => {
it('should forward positionerProps and popupProps when passthrough props are provided', async () => {
const onPopupClick = vi.fn()
const screen = await render(
<PreviewCard open>
<PreviewCardTrigger
render={<button type="button" aria-label="preview trigger">Open</button>}
/>
<PreviewCardContent
positionerProps={{
'role': 'group',
'aria-label': 'preview positioner',
'id': 'preview-positioner-id',
}}
popupProps={{
'id': 'preview-popup-id',
'role': 'dialog',
'aria-label': 'preview content',
'onClick': onPopupClick,
}}
>
<span>Preview body</span>
</PreviewCardContent>
</PreviewCard>,
)
const popup = screen.getByRole('dialog', { name: 'preview content' })
await popup.click()
await expect.element(screen.getByRole('group', { name: 'preview positioner' })).toHaveAttribute('id', 'preview-positioner-id')
await expect.element(popup).toHaveAttribute('id', 'preview-popup-id')
expect(onPopupClick).toHaveBeenCalledTimes(1)
})
})
describe('Trigger click behavior', () => {
it('should forward the trigger click to the consumer handler so the primary action runs', async () => {
const onPrimaryClick = vi.fn()
const screen = await renderWithSafeViewport(
<PreviewCard>
<PreviewCardTrigger
render={(
<button
type="button"
aria-label="preview trigger"
onClick={onPrimaryClick}
>
Open
</button>
)}
/>
<PreviewCardContent
popupProps={{ 'role': 'dialog', 'aria-label': 'preview content' }}
>
<span>Preview body</span>
</PreviewCardContent>
</PreviewCard>,
)
const trigger = screen.getByRole('button', { name: 'preview trigger' })
await trigger.click()
expect(onPrimaryClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,213 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Placement } from '.'
import { useState } from 'react'
import {
createPreviewCardHandle,
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '.'
const rowButtonClassName
= 'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover'
const triggerButtonClassName
= 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
const inlineLinkClassName
= 'text-text-accent underline decoration-text-accent/60 decoration-1 underline-offset-2 outline-hidden hover:decoration-text-accent focus-visible:rounded-xs focus-visible:no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-text-accent data-[popup-open]:decoration-text-accent'
const meta = {
title: 'Base/UI/PreviewCard',
component: PreviewCard,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Hover- and focus-activated rich preview for triggers whose primary click has its own destination (following a link, selecting a row, jumping to a definition). Built on Base UI PreviewCard.\n\n**A11y contract:** touch and screen-reader users cannot open the preview. Never place information or actions in the popup that are not also reachable from the trigger\'s primary click destination. If that is unavoidable, add a separate click affordance (Popover) or move the unique content onto the destination.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof PreviewCard>
export default meta
type Story = StoryObj<typeof meta>
// --- Canonical: inline link preview ---------------------------------------
// Mirrors Base UI's own PreviewCard docs demo: an inline `<a href>` in a
// paragraph, hovering reveals a rich preview (image + summary) of the link's
// destination. The Wikipedia URL and Unsplash image are the exact assets used
// in base-ui.com's public docs so the story renders a real preview.
// https://base-ui.com/react/components/preview-card
const typographyPreview = createPreviewCardHandle()
export const LinkPreview: Story = {
name: 'Link preview (canonical)',
parameters: {
docs: {
description: {
story:
'The prototypical PreviewCard use case: an inline hyperlink with a rich hover preview of the destination. Uses a detached trigger + `createPreviewCardHandle()` so the trigger can sit inline in prose while the popup content is defined elsewhere. The trigger renders a real `<a href>` — click still follows the link; the preview is strictly supplementary.',
},
},
},
render: () => (
<div className="max-w-md p-6 text-sm leading-6 text-text-secondary">
<p>
The principles of good
{' '}
<PreviewCardTrigger
handle={typographyPreview}
href="https://en.wikipedia.org/wiki/Typography"
target="_blank"
rel="noreferrer"
className={inlineLinkClassName}
>
typography
</PreviewCardTrigger>
{' '}
remain in the digital age.
</p>
<PreviewCard handle={typographyPreview}>
<PreviewCardContent popupClassName="w-[240px] p-2">
<div className="flex flex-col gap-2">
<img
width="224"
height="150"
className="block max-w-none rounded-md"
src="https://images.unsplash.com/photo-1619615391095-dfa29e1672ef?q=80&w=448&h=300"
alt="Station Hofplein signage in Rotterdam, Netherlands"
/>
<p className="m-0 text-xs leading-5 text-text-secondary">
<strong className="text-text-primary">Typography</strong>
{' '}
is the art and science of arranging type to make written language legible, readable, and visually appealing.
</p>
</div>
</PreviewCardContent>
</PreviewCard>
</div>
),
}
export const Supplementary: Story = {
name: 'Supplementary preview on a button trigger',
parameters: {
docs: {
description: {
story:
'Application-level adaptation of the same semantic: the trigger is a `<button>` that owns a primary action (selecting a model row) rather than an `<a>`. The preview still only shows supplementary info reachable from the selection destination, so the a11y contract holds.',
},
},
},
render: () => (
<PreviewCard>
<PreviewCardTrigger
render={(
<button type="button" className={rowButtonClassName}>
<span className="i-ri-sparkling-fill h-4 w-4 text-text-accent" />
<span>gpt-4o</span>
</button>
)}
/>
<PreviewCardContent
placement="right"
popupClassName="w-[220px] p-3"
>
<div className="flex flex-col gap-2">
<div className="text-sm font-medium text-text-primary">gpt-4o</div>
<div className="text-xs text-text-tertiary">
Multimodal flagship model. Vision, audio and 128k context.
</div>
</div>
</PreviewCardContent>
</PreviewCard>
),
}
const PLACEMENTS: Placement[] = [
'top-start',
'top',
'top-end',
'right-start',
'right',
'right-end',
'bottom-start',
'bottom',
'bottom-end',
'left-start',
'left',
'left-end',
]
const PlacementsDemo = () => {
const [placement, setPlacement] = useState<Placement>('bottom')
return (
<div className="flex flex-col items-center gap-4 p-20">
<div className="grid grid-cols-3 gap-2 text-xs">
{PLACEMENTS.map(value => (
<button
key={value}
type="button"
onClick={() => setPlacement(value)}
className={`rounded-md border border-divider-subtle px-2 py-1 text-text-secondary ${
placement === value ? 'bg-state-base-hover' : 'bg-components-button-secondary-bg'
}`}
>
{value}
</button>
))}
</div>
<PreviewCard open>
<PreviewCardTrigger
render={<button type="button" className={triggerButtonClassName}>Hover me</button>}
/>
<PreviewCardContent placement={placement} popupClassName="w-56 p-3">
<div className="flex flex-col gap-1">
<div className="text-sm font-semibold text-text-primary">
placement="
{placement}
"
</div>
<div className="text-xs text-text-secondary">
Preview positions itself relative to the trigger.
</div>
</div>
</PreviewCardContent>
</PreviewCard>
</div>
)
}
export const Placements: Story = {
parameters: {
layout: 'fullscreen',
},
render: () => <PlacementsDemo />,
}
const CustomDelayDemo = () => (
<PreviewCard>
<PreviewCardTrigger
delay={100}
closeDelay={100}
render={<button type="button" className={triggerButtonClassName}>Snappy trigger</button>}
/>
<PreviewCardContent popupClassName="w-64 p-3">
<div className="flex flex-col gap-1">
<div className="text-sm font-semibold text-text-primary">Fast hover</div>
<div className="text-xs text-text-secondary">
Base UI defaults (600ms / 300ms) are tuned for link previews. Override per trigger for denser UIs.
</div>
</div>
</PreviewCardContent>
</PreviewCard>
)
export const CustomDelays: Story = {
render: () => <CustomDelayDemo />,
}

View File

@ -0,0 +1,81 @@
'use client'
import type { ReactNode } from 'react'
import type { Placement } from '../placement'
import { PreviewCard as BasePreviewCard } from '@base-ui/react/preview-card'
import { cn } from '../cn'
import { parsePlacement } from '../placement'
export type { Placement }
/**
* PreviewCard is a hover/focus-triggered rich preview intended to supplement a
* trigger whose primary action is its own click destination (e.g. a link, a
* selectable row, a chip that jumps to a definition).
*
* A11y contract match Base UI's guidance:
* - The popup MUST NOT contain information or actions that are not also
* reachable from the trigger's primary click destination. Touch and screen
* reader users cannot open the card and must be able to get the same
* information/actions without it.
* - If content is unique to the popup, either (a) add a separate click-triggered
* affordance (Popover) next to the trigger, or (b) move the unique content
* onto the click destination.
*/
export const PreviewCard = BasePreviewCard.Root
export const PreviewCardTrigger = BasePreviewCard.Trigger
export const createPreviewCardHandle = BasePreviewCard.createHandle
type PreviewCardContentProps = {
children: ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: Omit<
BasePreviewCard.Positioner.Props,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
BasePreviewCard.Popup.Props,
'children' | 'className'
>
}
export function PreviewCardContent({
children,
placement = 'bottom',
sideOffset = 8,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: PreviewCardContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BasePreviewCard.Portal>
<BasePreviewCard.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-1002 outline-hidden', className)}
{...positionerProps}
>
<BasePreviewCard.Popup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
popupClassName,
)}
{...popupProps}
>
{children}
</BasePreviewCard.Popup>
</BasePreviewCard.Positioner>
</BasePreviewCard.Portal>
)
}

View File

@ -46,20 +46,7 @@ describe('TooltipContent', () => {
})
})
describe('Variant and popup props', () => {
it('should render popup content when variant is plain', async () => {
const screen = await render(
<Tooltip open>
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
<TooltipContent variant="plain" role="tooltip" aria-label="plain tooltip">
Plain tooltip body
</TooltipContent>
</Tooltip>,
)
await expect.element(screen.getByRole('tooltip', { name: 'plain tooltip' })).toHaveTextContent('Plain tooltip body')
})
describe('Popup props', () => {
it('should forward popup props and handlers when popup props are provided', async () => {
const onMouseEnter = vi.fn()
@ -83,7 +70,11 @@ describe('TooltipContent', () => {
await expect.element(popup).toHaveAttribute('id', 'tooltip-popup-id')
await expect.element(popup).toHaveAttribute('data-track-id', 'tooltip-track')
expect(onMouseEnter).toHaveBeenCalledTimes(1)
// Intent of the assertion is "handler is wired up". The exact call count
// depends on vitest-browser's pointer simulation and Base UI's internal
// pointer tracking (both of which may fire more than one enter event for
// a single `.hover()` action), so assert presence, not count.
expect(onMouseEnter).toHaveBeenCalled()
})
it('should apply className to the popup and positionerClassName to the positioner', async () => {

View File

@ -8,8 +8,8 @@ import {
TooltipTrigger,
} from '.'
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
const iconButtonClassName = 'inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-subtle bg-components-button-secondary-bg text-text-secondary shadow-xs hover:bg-state-base-hover'
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
const meta = {
title: 'Base/UI/Tooltip',
@ -25,7 +25,7 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement, offsets, and two style variants.',
component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement and offsets.\n\n**Usage contract** (mirrors the [Base UI tooltip guidelines](https://base-ui.com/react/components/tooltip#alternatives-to-tooltips)):\n\n- Tooltips are **supplementary visual labels** for sighted mouse and keyboard users. They are disabled on touch devices and are not announced to screen readers.\n- The trigger **must carry its own `aria-label` or visible text** that matches the tooltip — the tooltip does not replace labeling.\n- Keep content short and non-interactive (an icon-button label, a keyboard shortcut, one-word clarification).\n- **Do not** place descriptions, prose, links, or interactive controls inside a tooltip — touch and screen-reader users cannot reach them.\n- For hover-triggered rich previews that users move their cursor onto, use `PreviewCard` (dwell-able, structured content).\n- For an info icon that explains a concept (an "infotip"), or for any hover popup that needs interactive content or to reach touch/assistive-tech users, use `Popover` with `openOnHover` on the trigger.',
},
},
},
@ -35,47 +35,58 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Hover me
</TooltipTrigger>
<TooltipContent>
Tooltips describe interactive elements without a click.
</TooltipContent>
</Tooltip>
),
}
const ICON_ACTIONS = [
{ icon: 'i-ri-pencil-line', label: 'Edit' },
{ icon: 'i-ri-file-copy-line', label: 'Duplicate' },
{ icon: 'i-ri-archive-line', label: 'Archive' },
{ icon: 'i-ri-delete-bin-line', label: 'Delete' },
] as const
export const Plain: Story = {
export const IconButton: Story = {
name: 'Icon button (canonical)',
parameters: {
docs: {
description: {
story: 'Use `variant="plain"` to render the popup without default chrome (background, padding, typography). Apply your own styling via `className` on `TooltipContent`.',
story: 'The canonical tooltip use case: an icon-only button surfaces its accessible label as a tooltip for sighted mouse and keyboard users. The trigger already carries `aria-label` — the tooltip mirrors that label visually; it does **not** replace it.',
},
},
},
render: () => (
<div className="flex items-center gap-3">
{ICON_ACTIONS.map(({ icon, label }) => (
<Tooltip key={label}>
<TooltipTrigger
render={(
<button type="button" aria-label={label} className={iconButtonClassName}>
<span aria-hidden className={`${icon} h-4 w-4`} />
</button>
)}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
))}
</div>
),
}
export const KeyboardShortcut: Story = {
parameters: {
docs: {
description: {
story: 'A short, supplementary hint that surfaces a keyboard shortcut next to a visible button label. The trigger is fully self-describing ("Save"); the tooltip only adds non-essential extra clarity for mouse/keyboard users.',
},
},
},
render: () => (
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Preview details
</TooltipTrigger>
<TooltipContent
variant="plain"
className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-lg"
>
<div className="flex w-64 flex-col gap-1">
<span className="text-sm font-semibold text-text-primary">Dataset preview</span>
<span className="text-xs text-text-secondary">
32 documents Last indexed 2 minutes ago
</span>
</div>
</TooltipContent>
render={(
<button type="button" className={triggerButtonClassName}>
Save
</button>
)}
/>
<TooltipContent>S</TooltipContent>
</Tooltip>
),
}
@ -116,14 +127,10 @@ const PlacementsDemo = () => {
</div>
<Tooltip open>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Anchor
</TooltipTrigger>
render={<button type="button" aria-label="Placement anchor" className={iconButtonClassName}><span aria-hidden className="i-ri-pushpin-line h-4 w-4" /></button>}
/>
<TooltipContent placement={placement}>
placement="
{placement}
"
{`placement="${placement}"`}
</TooltipContent>
</Tooltip>
</div>
@ -133,113 +140,45 @@ const PlacementsDemo = () => {
export const Placements: Story = {
parameters: {
layout: 'fullscreen',
docs: {
description: {
story: 'Placement reference. `placement` accepts the 12 standard side/align combinations; Base UI flips automatically if the tooltip would overflow the viewport.',
},
},
},
render: () => <PlacementsDemo />,
}
export const OnIconButtons: Story = {
parameters: {
docs: {
description: {
story: 'Tooltips are essential for icon-only buttons. The trigger is the button; the tooltip provides the accessible label and hover hint.',
},
},
},
render: () => (
<div className="flex items-center gap-3">
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label="Edit" className={iconButtonClassName}>
<span aria-hidden className="i-ri-pencil-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label="Duplicate" className={iconButtonClassName}>
<span aria-hidden className="i-ri-file-copy-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>Duplicate</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label="Archive" className={iconButtonClassName}>
<span aria-hidden className="i-ri-archive-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>Archive</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label="Delete" className={iconButtonClassName}>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
),
}
export const LongContent: Story = {
render: () => (
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
What are tokens?
</TooltipTrigger>
<TooltipContent>
Tokens are the basic units a model reads. English text averages ~4 characters per token; non-Latin scripts often use more tokens per character. Both input and output count toward your quota.
</TooltipContent>
</Tooltip>
),
}
const DELAY_PRESETS: Array<{ label: string, delay: number }> = [
{ label: 'Instant (0ms)', delay: 0 },
{ label: 'Fast (150ms)', delay: 150 },
{ label: 'Default (600ms)', delay: 600 },
{ label: 'Instant', delay: 0 },
{ label: 'Fast', delay: 150 },
{ label: 'Default', delay: 600 },
]
const DelayDemo = () => {
return (
<div className="flex items-center gap-3">
{DELAY_PRESETS.map(({ label, delay }) => (
<TooltipProvider key={delay} delay={delay}>
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
{label}
</TooltipTrigger>
<TooltipContent>
Appeared after
{delay}
ms hover delay.
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
)
}
const DelayDemo = () => (
<div className="flex items-center gap-3">
{DELAY_PRESETS.map(({ label, delay }) => (
<TooltipProvider key={delay} delay={delay}>
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label={`${label} (${delay}ms)`} className={iconButtonClassName}>
<span aria-hidden className="i-ri-timer-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>{`${label} (${delay}ms)`}</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
)
export const WithDelay: Story = {
parameters: {
docs: {
description: {
story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown.',
story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown. The Dify app root sets `delay={300} closeDelay={200}` — override locally only when the surrounding UX demands it.',
},
},
},

View File

@ -8,7 +8,28 @@ import { parsePlacement } from '../placement'
export type { Placement }
type TooltipContentVariant = 'default' | 'plain'
/**
* Tooltip is an **ephemeral hint** tied to a trigger (typically an icon button,
* badge, or short label). It follows Base UI's Tooltip semantics:
*
* - Opens on pointer hover or keyboard focus on the trigger.
* - Closes as soon as the pointer leaves the trigger the popup itself is
* **not dwell-able**; users cannot move their cursor onto the tooltip.
* - Must contain only short, non-interactive text. No links, buttons, form
* controls, or structured panels.
*
* If you need any of the following, use `PreviewCard` instead (hover-triggered
* rich preview that users can move their cursor onto):
*
* - Multi-line or structured content (icon + title + metadata)
* - Content the user needs to "stop and read" for more than ~1 second
* - Content wider than ~300px
*
* If you need interactive affordances (buttons, links, forms) use `Popover`.
*/
export const TooltipProvider = BaseTooltip.Provider
export const Tooltip = BaseTooltip.Root
export const TooltipTrigger = BaseTooltip.Trigger
type TooltipContentProps = {
children: ReactNode
@ -17,7 +38,6 @@ type TooltipContentProps = {
alignOffset?: number
positionerClassName?: string
className?: string
variant?: TooltipContentVariant
} & Omit<BaseTooltip.Popup.Props, 'children' | 'className'>
export function TooltipContent({
@ -27,7 +47,6 @@ export function TooltipContent({
alignOffset = 0,
positionerClassName,
className,
variant = 'default',
...props
}: TooltipContentProps) {
const { side, align } = parsePlacement(placement)
@ -43,7 +62,7 @@ export function TooltipContent({
>
<BaseTooltip.Popup
className={cn(
variant === 'default' && 'max-w-[300px] rounded-md bg-components-panel-bg px-3 py-2 text-left system-xs-regular wrap-break-word text-text-tertiary shadow-lg',
'max-w-[300px] rounded-md bg-components-panel-bg px-3 py-2 text-left system-xs-regular wrap-break-word text-text-tertiary shadow-lg',
'origin-(--transform-origin) transition-opacity data-ending-style:opacity-0 data-instant:transition-none data-starting-style:opacity-0 motion-reduce:transition-none',
className,
)}
@ -55,7 +74,3 @@ export function TooltipContent({
</BaseTooltip.Portal>
)
}
export const TooltipProvider = BaseTooltip.Provider
export const Tooltip = BaseTooltip.Root
export const TooltipTrigger = BaseTooltip.Trigger

View File

@ -0,0 +1,82 @@
'use client'
import type { Placement } from '@langgenius/dify-ui/popover'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
/**
* Infotip a `?` icon that reveals a long-form explanation on hover / focus / tap.
*
* Implements the pattern Base UI calls an "infotip":
* https://base-ui.com/react/components/tooltip#infotips
*
* > "Popups that open when hovering an info icon should use Popover with the
* > `openOnHover` prop on the trigger instead of a tooltip. This way, touch
* > users and screen reader users can access the content."
*
* Use whenever the trigger is an info glyph whose sole purpose is to open a
* popup (help text, documentation-style explanation). Do NOT use `Tooltip` for
* this Tooltip is reserved for ephemeral, non-interactive visual labels that
* are unreachable on touch devices and by screen readers.
*
* Base UI rule of thumb:
*
* > "If the trigger's purpose is to open the popup itself, it's a popover.
* > If the trigger's purpose is unrelated to opening the popup, it's a tooltip."
*
* For hover-revealed supplementary previews of a link / row trigger that has
* its own primary click destination, use `PreviewCard` instead.
*/
type InfotipProps = {
/** Popup content. Rich nodes are allowed. */
'children': ReactNode
/** Accessible name for the trigger. Required; should match the popup text. */
'aria-label': string
/** Placement of the popup relative to the trigger. Defaults to `top`. */
'placement'?: Placement
/** Extra classes on the outer trigger wrapper (layout / margin). */
'className'?: string
/** Extra classes on the `?` icon itself (size / color overrides). */
'iconClassName'?: string
/** Extra classes on the popup body (width / padding / whitespace overrides). */
'popupClassName'?: string
/** Hover open delay in ms. Defaults to 300 to match the app-wide Tooltip delay. */
'delay'?: number
/** Hover close delay in ms. Defaults to 200 to match the app-wide Tooltip delay. */
'closeDelay'?: number
}
export function Infotip({
children,
'aria-label': ariaLabel,
placement = 'top',
className,
iconClassName,
popupClassName,
delay = 300,
closeDelay = 200,
}: InfotipProps) {
return (
<Popover>
<PopoverTrigger
openOnHover
delay={delay}
closeDelay={closeDelay}
aria-label={ariaLabel}
render={(
<span className={cn('inline-flex h-4 w-4 shrink-0 items-center justify-center', className)}>
<span aria-hidden className={cn('i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary', iconClassName)} />
</span>
)}
/>
<PopoverContent
placement={placement}
popupClassName={cn('max-w-[300px] px-3 py-2 system-xs-regular text-text-tertiary', popupClassName)}
>
{children}
</PopoverContent>
</Popover>
)
}

View File

@ -302,8 +302,8 @@ describe('HITLInputVariableBlockComponent', () => {
})
})
describe('Tooltip payload', () => {
it('should call getVarType with rag selector and use rag node id mapping', () => {
describe('Full-path preview payload', () => {
it('should resolve the rag node via isRagVar offset and skip the full-path preview', () => {
const getVarType = vi.fn(() => Type.number)
const { container } = renderVariableBlock({
variables: ['rag', 'node-rag', 'chunk'],
@ -314,10 +314,9 @@ describe('HITLInputVariableBlockComponent', () => {
expect(screen.getByText('chunk')).toBeInTheDocument()
expect(hasErrorIcon(container)).toBe(false)
expect(getVarType).toHaveBeenCalledWith({
nodeId: 'rag',
valueSelector: ['rag', 'node-rag', 'chunk'],
})
// Rag selectors always have `isShowAPart === false`, so the full-path
// preview is not rendered and `getVarType` is not invoked.
expect(getVarType).not.toHaveBeenCalled()
})
it('should use shortened display name for deep non-rag selectors', () => {

View File

@ -1,6 +1,7 @@
import type { UpdateWorkflowNodesMapPayload } from '../workflow-variable-block'
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
@ -13,7 +14,6 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import {
isConversationVar,
isENV,
@ -119,13 +119,13 @@ const HITLInputVariableBlockComponent = ({
/>
)
if (!node)
if (!node || !isShowAPart)
return Item
return (
<Tooltip
noDecoration
popupContent={(
<PreviewCard>
<PreviewCardTrigger delay={300} closeDelay={200} render={<div>{Item}</div>} />
<PreviewCardContent popupClassName="border-0 bg-transparent p-0 shadow-none">
<VarFullPathPanel
nodeName={node.title}
path={variables.slice(1)}
@ -137,11 +137,8 @@ const HITLInputVariableBlockComponent = ({
: Type.string}
nodeType={node?.type}
/>
)}
disabled={!isShowAPart}
>
<div>{Item}</div>
</Tooltip>
</PreviewCardContent>
</PreviewCard>
)
}

View File

@ -3,7 +3,7 @@ import type {
} from './index'
import type { WorkflowNodesMap } from './node'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
@ -151,13 +151,13 @@ const WorkflowVariableBlockComponent = ({
/>
)
if (!node)
if (!node || !isShowAPart)
return Item
return (
<Tooltip>
<TooltipTrigger disabled={!isShowAPart} render={<div>{Item}</div>} />
<TooltipContent variant="plain">
<PreviewCard>
<PreviewCardTrigger delay={300} closeDelay={200} render={<div>{Item}</div>} />
<PreviewCardContent popupClassName="border-0 bg-transparent p-0 shadow-none">
<VarFullPathPanel
nodeName={node.title}
path={variables.slice(1)}
@ -169,8 +169,8 @@ const WorkflowVariableBlockComponent = ({
: Type.string}
nodeType={node?.type}
/>
</TooltipContent>
</Tooltip>
</PreviewCardContent>
</PreviewCard>
)
}

View File

@ -7,9 +7,9 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select'
import { Slider } from '@langgenius/dify-ui/slider'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import PromptEditor from '@/app/components/base/prompt-editor'
import Radio from '@/app/components/base/radio'
import TagInput from '@/app/components/base/tag-input'
@ -349,18 +349,13 @@ function ParameterItem({
</div>
{
parameterRule.help && (
<Tooltip>
<TooltipTrigger
render={(
<span className="mr-1 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary" />
</span>
)}
/>
<TooltipContent className="mr-1">
<div className="w-[150px] whitespace-pre-wrap">{parameterRule.help[language] || parameterRule.help.en_US}</div>
</TooltipContent>
</Tooltip>
<Infotip
aria-label={parameterRule.help[language] || parameterRule.help.en_US}
className="mr-1"
popupClassName="w-[150px] whitespace-pre-wrap"
>
{parameterRule.help[language] || parameterRule.help.en_US}
</Infotip>
)
}
</div>

View File

@ -10,7 +10,11 @@ import {
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
@ -160,8 +164,13 @@ const PopupItem: FC<PopupItemProps> = ({
</Popover>
</div>
{!collapsed && model.models.map(modelItem => (
<Tooltip key={modelItem.model}>
<TooltipTrigger
// Preview is supplementary: every field in it (name / type / mode / context size / capabilities)
// is reachable from the model's own configuration surface once the row is selected.
// Touch + screen reader users rely on the button's primary onClick, not the preview.
<PreviewCard key={modelItem.model}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
render={(
<button
type="button"
@ -197,10 +206,9 @@ const PopupItem: FC<PopupItemProps> = ({
</button>
)}
/>
<TooltipContent
<PreviewCardContent
placement="right"
variant="plain"
className="w-[206px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 backdrop-blur-xs"
popupClassName="w-[206px] bg-components-panel-bg-blur p-3 shadow-none backdrop-blur-xs"
>
<div className="flex flex-col gap-1">
<div className="flex flex-col items-start gap-2">
@ -245,8 +253,8 @@ const PopupItem: FC<PopupItemProps> = ({
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</PreviewCardContent>
</PreviewCard>
))}
</div>
)

View File

@ -225,7 +225,7 @@ const Popup: FC<PopupProps> = ({
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<div className="px-1 pb-1">
<div className="pr-1 pb-1 pl-3">
{
filteredModelList.map(model => (
<PopupItem

View File

@ -1,7 +1,7 @@
import type { UsagePriority } from '../use-credential-panel-state'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { PreferredProviderTypeEnum } from '../../declarations'
type UsagePrioritySectionProps = {
@ -20,6 +20,7 @@ export default function UsagePrioritySection({ value, disabled, onSelect }: Usag
const selectedKey = value === 'credits'
? PreferredProviderTypeEnum.system
: PreferredProviderTypeEnum.custom
const usagePriorityTip = t('modelProvider.card.usagePriorityTip', { ns: 'common' })
return (
<div className="p-1">
@ -31,19 +32,9 @@ export default function UsagePrioritySection({ value, disabled, onSelect }: Usag
<span className="truncate system-sm-medium text-text-secondary">
{t('modelProvider.card.usagePriority', { ns: 'common' })}
</span>
<Tooltip>
<TooltipTrigger
aria-label={t('modelProvider.card.usagePriorityTip', { ns: 'common' })}
render={(
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
{t('modelProvider.card.usagePriorityTip', { ns: 'common' })}
</TooltipContent>
</Tooltip>
<Infotip aria-label={usagePriorityTip}>
{usagePriorityTip}
</Infotip>
</div>
<div className="flex shrink-0 items-center gap-1">
{options.map(option => (

View File

@ -9,6 +9,7 @@ import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Loading from '@/app/components/base/loading'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import useTimestamp from '@/hooks/use-timestamp'
@ -100,19 +101,9 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
<div className="relative">
<div className="mb-2 flex h-4 items-center system-xs-medium-uppercase text-text-tertiary">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip>
<TooltipTrigger
aria-label={tipText}
render={(
<span className="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
{tipText}
</TooltipContent>
</Tooltip>
<Infotip aria-label={tipText} className="ml-0.5">
{tipText}
</Infotip>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">

View File

@ -11,13 +11,9 @@ import {
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { updateDefaultModel } from '@/service/common'
@ -138,21 +134,13 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
return (
<div className="flex min-h-6 items-center text-[13px] font-medium text-text-secondary">
{t(labelKey, { ns: 'common' })}
<Tooltip>
<TooltipTrigger
aria-label={tipText}
render={(
<span className="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
<div className="w-[261px] text-text-tertiary">
{tipText}
</div>
</TooltipContent>
</Tooltip>
<Infotip
aria-label={tipText}
className="ml-0.5"
popupClassName="w-[261px]"
>
{tipText}
</Infotip>
</div>
)
}

View File

@ -1,5 +1,10 @@
import type { NodeDefault } from '../types'
import type { BlockClassificationEnum } from './types'
import {
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { groupBy } from 'es-toolkit/compat'
import {
memo,
@ -9,7 +14,6 @@ import {
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
import { BLOCK_CLASSIFICATIONS } from './constants'
@ -92,13 +96,40 @@ const Blocks = ({
)
}
{
// Preview is supplementary: icon/title/description are all reachable
// from the node that gets added on click (inspector + canvas), so
// hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
filteredList.map(block => (
<Tooltip
key={block.metaData.type}
position="right"
popupClassName="w-[200px] rounded-xl"
needsDelay={false}
popupContent={(
<PreviewCard key={block.metaData.type}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
render={(
<div
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(block.metaData.type)}
>
<BlockIcon
className="mr-2 shrink-0"
type={block.metaData.type}
/>
<div className="grow text-sm text-text-secondary">{block.metaData.title}</div>
{
block.metaData.type === BlockEnum.LoopEnd && (
<Badge
text={t('nodes.loop.loopNode', { ns: 'workflow' })}
className="ml-2 shrink-0"
/>
)
}
</div>
)}
/>
<PreviewCardContent
placement="right"
popupClassName="w-[200px] border-none px-3 py-2"
>
<div>
<BlockIcon
size="md"
@ -108,28 +139,8 @@ const Blocks = ({
<div className="mb-1 system-md-medium text-text-primary">{block.metaData.title}</div>
<div className="system-xs-regular text-text-tertiary">{block.metaData.description}</div>
</div>
)}
>
<div
key={block.metaData.type}
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(block.metaData.type)}
>
<BlockIcon
className="mr-2 shrink-0"
type={block.metaData.type}
/>
<div className="grow text-sm text-text-secondary">{block.metaData.title}</div>
{
block.metaData.type === BlockEnum.LoopEnd && (
<Badge
text={t('nodes.loop.loopNode', { ns: 'workflow' })}
className="ml-2 shrink-0"
/>
)
}
</div>
</Tooltip>
</PreviewCardContent>
</PreviewCard>
))
}
</div>

View File

@ -3,12 +3,12 @@ import type { ToolWithProvider } from '../types'
import type { ToolDefaultValue, ToolValue } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { RiMoreLine } from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import { useGetLanguage } from '@/context/i18n'
@ -235,7 +235,6 @@ function FeaturedToolUninstalledItem({
const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief
const installCountLabel = t('install', { ns: 'plugin', num: formatNumber(plugin.install_count || 0) })
const [actionOpen, setActionOpen] = useState(false)
const [isActionHovered, setIsActionHovered] = useState(false)
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false)
useEffect(() => {
@ -244,7 +243,6 @@ function FeaturedToolUninstalledItem({
const handleScroll = () => {
setActionOpen(false)
setIsActionHovered(false)
}
window.addEventListener('scroll', handleScroll, true)
@ -254,77 +252,72 @@ function FeaturedToolUninstalledItem({
}
}, [actionOpen])
const row = (
<div
className="group flex h-8 w-full items-center rounded-lg pr-1 pl-3 hover:bg-state-base-hover"
>
<div className="flex h-full min-w-0 items-center">
<BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} />
<div className="ml-2 min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
</div>
</div>
<div className="ml-auto flex h-full items-center gap-1 pl-1">
<span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span>
<div
className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
>
<button
type="button"
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
onClick={() => {
setActionOpen(false)
setIsInstallModalOpen(true)
}}
>
{t('installAction', { ns: 'plugin' })}
</button>
<Action
open={actionOpen}
onOpenChange={setActionOpen}
author={plugin.org}
name={plugin.name}
version={plugin.latest_version}
/>
</div>
</div>
</div>
)
return (
<>
<Tooltip
position="right"
needsDelay={false}
popupClassName="p-0! px-3! py-2.5! w-[224px]! leading-[18px]! text-xs! text-gray-700! border-[0.5px]! border-black/5! rounded-xl! shadow-lg!"
popupContent={(
<div>
<BlockIcon size="md" className="mb-2" type={BlockEnum.Tool} toolIcon={plugin.icon} />
<div className="mb-1 text-sm leading-5 text-text-primary">{label}</div>
<div className="text-xs leading-[18px] text-text-secondary">{description}</div>
</div>
)}
disabled={!description || isActionHovered || actionOpen || isInstallModalOpen}
>
<div
className="group flex h-8 w-full items-center rounded-lg pr-1 pl-3 hover:bg-state-base-hover"
>
<div className="flex h-full min-w-0 items-center">
<BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} />
<div className="ml-2 min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
</div>
</div>
<div className="ml-auto flex h-full items-center gap-1 pl-1">
<span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span>
<div
className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
onMouseEnter={() => setIsActionHovered(true)}
onMouseLeave={() => {
if (!actionOpen)
setIsActionHovered(false)
}}
>
<button
type="button"
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
onClick={() => {
setActionOpen(false)
setIsInstallModalOpen(true)
setIsActionHovered(true)
}}
>
{t('installAction', { ns: 'plugin' })}
</button>
<Action
open={actionOpen}
onOpenChange={(value) => {
setActionOpen(value)
setIsActionHovered(value)
}}
author={plugin.org}
name={plugin.name}
version={plugin.latest_version}
/>
</div>
</div>
</div>
</Tooltip>
{description
? (
// Preview is supplementary: icon / label / brief are all reachable from
// the InstallFromMarketplace modal that opens on click, so hover/focus-only
// activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCard>
<PreviewCardTrigger delay={150} closeDelay={150} render={row} />
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon size="md" className="mb-2" type={BlockEnum.Tool} toolIcon={plugin.icon} />
<div className="mb-1 text-sm leading-5 text-text-primary">{label}</div>
<div className="text-xs leading-[18px] text-text-secondary">{description}</div>
</div>
</PreviewCardContent>
</PreviewCard>
)
: row}
{isInstallModalOpen && (
<InstallFromMarketplace
uniqueIdentifier={plugin.latest_package_identifier}
manifest={plugin}
onSuccess={async () => {
setIsInstallModalOpen(false)
setIsActionHovered(false)
await onInstallSuccess?.()
}}
onClose={() => {
setIsInstallModalOpen(false)
setIsActionHovered(false)
}}
/>
)}

View File

@ -2,12 +2,12 @@
import type { TriggerDefaultValue, TriggerWithProvider } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { RiMoreLine } from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import { useGetLanguage } from '@/context/i18n'
@ -230,7 +230,6 @@ function FeaturedTriggerUninstalledItem({
const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief
const installCountLabel = t('install', { ns: 'plugin', num: formatNumber(plugin.install_count || 0) })
const [actionOpen, setActionOpen] = useState(false)
const [isActionHovered, setIsActionHovered] = useState(false)
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false)
useEffect(() => {
@ -239,7 +238,6 @@ function FeaturedTriggerUninstalledItem({
const handleScroll = () => {
setActionOpen(false)
setIsActionHovered(false)
}
window.addEventListener('scroll', handleScroll, true)
@ -249,77 +247,72 @@ function FeaturedTriggerUninstalledItem({
}
}, [actionOpen])
const row = (
<div
className="group flex h-8 w-full items-center rounded-lg pr-1 pl-3 hover:bg-state-base-hover"
>
<div className="flex h-full min-w-0 items-center">
<BlockIcon type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
<div className="ml-2 min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
</div>
</div>
<div className="ml-auto flex h-full items-center gap-1 pl-1">
<span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span>
<div
className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
>
<button
type="button"
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
onClick={() => {
setActionOpen(false)
setIsInstallModalOpen(true)
}}
>
{t('installAction', { ns: 'plugin' })}
</button>
<Action
open={actionOpen}
onOpenChange={setActionOpen}
author={plugin.org}
name={plugin.name}
version={plugin.latest_version}
/>
</div>
</div>
</div>
)
return (
<>
<Tooltip
position="right"
needsDelay={false}
popupClassName="p-0! px-3! py-2.5! w-[224px]! leading-[18px]! text-xs! text-gray-700! border-[0.5px]! border-black/5! rounded-xl! shadow-lg!"
popupContent={(
<div>
<BlockIcon size="md" className="mb-2" type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
<div className="mb-1 text-sm leading-5 text-text-primary">{label}</div>
<div className="text-xs leading-[18px] text-text-secondary">{description}</div>
</div>
)}
disabled={!description || isActionHovered || actionOpen || isInstallModalOpen}
>
<div
className="group flex h-8 w-full items-center rounded-lg pr-1 pl-3 hover:bg-state-base-hover"
>
<div className="flex h-full min-w-0 items-center">
<BlockIcon type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
<div className="ml-2 min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{label}</div>
</div>
</div>
<div className="ml-auto flex h-full items-center gap-1 pl-1">
<span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span>
<div
className={`flex h-full items-center gap-1 system-xs-medium text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? '' : 'hidden group-hover:flex'}`}
onMouseEnter={() => setIsActionHovered(true)}
onMouseLeave={() => {
if (!actionOpen)
setIsActionHovered(false)
}}
>
<button
type="button"
className="cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover"
onClick={() => {
setActionOpen(false)
setIsInstallModalOpen(true)
setIsActionHovered(true)
}}
>
{t('installAction', { ns: 'plugin' })}
</button>
<Action
open={actionOpen}
onOpenChange={(value) => {
setActionOpen(value)
setIsActionHovered(value)
}}
author={plugin.org}
name={plugin.name}
version={plugin.latest_version}
/>
</div>
</div>
</div>
</Tooltip>
{description
? (
// Preview is supplementary: icon / label / brief are all reachable from
// the InstallFromMarketplace modal that opens on click, so hover/focus-only
// activation is a11y-safe. See packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCard>
<PreviewCardTrigger delay={150} closeDelay={150} render={row} />
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon size="md" className="mb-2" type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
<div className="mb-1 text-sm leading-5 text-text-primary">{label}</div>
<div className="text-xs leading-[18px] text-text-secondary">{description}</div>
</div>
</PreviewCardContent>
</PreviewCard>
)
: row}
{isInstallModalOpen && (
<InstallFromMarketplace
uniqueIdentifier={plugin.latest_package_identifier}
manifest={plugin}
onSuccess={async () => {
setIsInstallModalOpen(false)
setIsActionHovered(false)
await onInstallSuccess?.()
}}
onClose={() => {
setIsInstallModalOpen(false)
setIsActionHovered(false)
}}
/>
)}

View File

@ -1,5 +1,10 @@
import type { BlockEnum, CommonNodeType } from '../types'
import type { TriggerDefaultValue } from './types'
import {
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import {
memo,
useCallback,
@ -7,9 +12,7 @@ import {
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { useAvailableNodesMetaData } from '../../workflow-app/hooks'
import BlockIcon from '../block-icon'
import { BlockEnum as BlockEnumValues } from '../types'
// import { useNodeMetaData } from '../hooks'
@ -33,7 +36,6 @@ const StartBlocks = ({
const { t } = useTranslation()
const nodes = useNodes()
// const nodeMetaData = useNodeMetaData()
const availableNodesMetaData = useAvailableNodesMetaData()
const filteredBlocks = useMemo(() => {
// Check if Start node already exists in workflow
@ -67,13 +69,34 @@ const StartBlocks = ({
onContentStateChange?.(!isEmpty)
}, [isEmpty, onContentStateChange])
// Preview is supplementary: the block icon, title and description all become
// reachable from the inspector + canvas once the row is clicked to insert
// the start node, so hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
const renderBlock = useCallback((block: typeof START_BLOCKS[number]) => (
<Tooltip
key={block.type}
position="right"
popupClassName="w-[224px] rounded-xl"
needsDelay={false}
popupContent={(
<PreviewCard key={block.type}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
render={(
<div
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(block.type)}
>
<BlockIcon
className="mr-2 shrink-0"
type={block.type}
/>
<div className="flex w-0 grow items-center justify-between text-sm text-text-secondary">
<span className="truncate">{t(`blocks.${block.type}`, { ns: 'workflow' })}</span>
{block.type === BlockEnumValues.Start && (
<span className="ml-2 shrink-0 system-xs-regular text-text-quaternary">{t('blocks.originalStartNode', { ns: 'workflow' })}</span>
)}
</div>
</div>
)}
/>
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
@ -96,25 +119,9 @@ const StartBlocks = ({
</div>
)}
</div>
)}
>
<div
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(block.type)}
>
<BlockIcon
className="mr-2 shrink-0"
type={block.type}
/>
<div className="flex w-0 grow items-center justify-between text-sm text-text-secondary">
<span className="truncate">{t(`blocks.${block.type}`, { ns: 'workflow' })}</span>
{block.type === BlockEnumValues.Start && (
<span className="ml-2 shrink-0 system-xs-regular text-text-quaternary">{t('blocks.originalStartNode', { ns: 'workflow' })}</span>
)}
</div>
</div>
</Tooltip>
), [availableNodesMetaData, onSelect, t])
</PreviewCardContent>
</PreviewCard>
), [onSelect, t])
if (isEmpty)
return null

View File

@ -4,11 +4,11 @@ import type { ToolWithProvider } from '../../types'
import type { ToolDefaultValue } from '../types'
import type { Tool } from '@/app/components/tools/types'
import { cn } from '@langgenius/dify-ui/cn'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Tooltip from '@/app/components/base/tooltip'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
@ -57,13 +57,59 @@ const ToolItem: FC<Props> = ({
return normalizedIcon
}, [theme, normalizedIcon, normalizedIconDark])
return (
<Tooltip
const row = (
<div
key={payload.name}
position="right"
needsDelay={false}
popupClassName="p-0! px-3! py-2.5! w-[200px]! leading-[18px]! text-xs! text-gray-700! border-[0.5px]! border-black/5! rounded-xl! shadow-lg!"
popupContent={(
className="flex cursor-pointer items-center justify-between rounded-lg pr-1 pl-[21px] hover:bg-state-base-hover"
onClick={() => {
if (disabled)
return
const params: Record<string, string> = {}
if (payload.parameters) {
payload.parameters.forEach((item) => {
params[item.name] = ''
})
}
onSelect(BlockEnum.Tool, {
provider_id: provider.id,
provider_type: provider.type,
provider_name: provider.name,
plugin_id: provider.plugin_id,
plugin_unique_identifier: provider.plugin_unique_identifier,
provider_icon: normalizedIcon,
provider_icon_dark: normalizedIconDark,
tool_name: payload.name,
tool_label: payload.label[language]!,
tool_description: payload.description[language],
title: payload.label[language]!,
is_team_authorization: provider.is_team_authorization,
paramSchemas: payload.parameters,
params,
meta: provider.meta,
})
trackEvent('tool_selected', {
tool_name: payload.name,
plugin_id: provider.plugin_id,
})
}}
>
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary')}>
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
<div className="mr-4 system-xs-regular text-text-tertiary">{t('addToolModal.added', { ns: 'tools' })}</div>
)}
</div>
)
return (
// Preview is supplementary: provider icon, tool label and description are all
// reachable from the node inspector after the row is clicked to add the tool,
// so hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCard key={payload.name}>
<PreviewCardTrigger delay={150} closeDelay={150} render={row} />
<PreviewCardContent placement="right" popupClassName="w-[200px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
@ -74,51 +120,8 @@ const ToolItem: FC<Props> = ({
<div className="mb-1 text-sm leading-5 text-text-primary">{payload.label[language]}</div>
<div className="text-xs leading-[18px] text-text-secondary">{payload.description[language]}</div>
</div>
)}
>
<div
key={payload.name}
className="flex cursor-pointer items-center justify-between rounded-lg pr-1 pl-[21px] hover:bg-state-base-hover"
onClick={() => {
if (disabled)
return
const params: Record<string, string> = {}
if (payload.parameters) {
payload.parameters.forEach((item) => {
params[item.name] = ''
})
}
onSelect(BlockEnum.Tool, {
provider_id: provider.id,
provider_type: provider.type,
provider_name: provider.name,
plugin_id: provider.plugin_id,
plugin_unique_identifier: provider.plugin_unique_identifier,
provider_icon: normalizedIcon,
provider_icon_dark: normalizedIconDark,
tool_name: payload.name,
tool_label: payload.label[language]!,
tool_description: payload.description[language],
title: payload.label[language]!,
is_team_authorization: provider.is_team_authorization,
paramSchemas: payload.parameters,
params,
meta: provider.meta,
})
trackEvent('tool_selected', {
tool_name: payload.name,
plugin_id: provider.plugin_id,
})
}}
>
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary')}>
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
<div className="mr-4 system-xs-regular text-text-tertiary">{t('addToolModal.added', { ns: 'tools' })}</div>
)}
</div>
</Tooltip>
</PreviewCardContent>
</PreviewCard>
)
}
export default React.memo(ToolItem)

View File

@ -3,9 +3,9 @@ import type { FC } from 'react'
import type { TriggerDefaultValue, TriggerWithProvider } from '../types'
import type { Event } from '@/app/components/tools/types'
import { cn } from '@langgenius/dify-ui/cn'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { useGetLanguage } from '@/context/i18n'
import BlockIcon from '../../block-icon'
import { BlockEnum } from '../../types'
@ -28,13 +28,54 @@ const TriggerPluginActionItem: FC<Props> = ({
const { t } = useTranslation()
const language = useGetLanguage()
return (
<Tooltip
const row = (
<div
key={payload.name}
position="right"
needsDelay={false}
popupClassName="p-0! px-3! py-2.5! w-[224px]! leading-[18px]! text-xs! text-gray-700! border-[0.5px]! border-black/5! rounded-xl! shadow-lg!"
popupContent={(
className="flex cursor-pointer items-center justify-between rounded-lg pr-1 pl-[21px] hover:bg-state-base-hover"
onClick={() => {
if (disabled)
return
const params: Record<string, string> = {}
if (payload.parameters) {
payload.parameters.forEach((item: any) => {
params[item.name] = ''
})
}
onSelect(BlockEnum.TriggerPlugin, {
plugin_id: provider.plugin_id,
provider_id: provider.name,
provider_type: provider.type as string,
provider_name: provider.name,
event_name: payload.name,
event_label: payload.label[language]!,
event_description: payload.description[language]!,
plugin_unique_identifier: provider.plugin_unique_identifier,
title: payload.label[language]!,
is_team_authorization: provider.is_team_authorization,
output_schema: payload.output_schema || {},
paramSchemas: payload.parameters,
params,
meta: provider.meta,
})
}}
>
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary')}>
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
<div className="mr-4 system-xs-regular text-text-tertiary">{t('addToolModal.added', { ns: 'tools' })}</div>
)}
</div>
)
return (
// Preview is supplementary: provider icon, event label and description are all
// reachable from the node inspector after the row is clicked to add the trigger,
// so hover/focus-only activation is a11y-safe. See
// packages/dify-ui/AGENTS.md → Overlay Primitive Selection.
<PreviewCard key={payload.name}>
<PreviewCardTrigger delay={150} closeDelay={150} render={row} />
<PreviewCardContent placement="right" popupClassName="w-[224px] px-3 py-2.5">
<div>
<BlockIcon
size="md"
@ -45,46 +86,8 @@ const TriggerPluginActionItem: FC<Props> = ({
<div className="mb-1 text-sm leading-5 text-text-primary">{payload.label[language]}</div>
<div className="text-xs leading-[18px] text-text-secondary">{payload.description[language]}</div>
</div>
)}
>
<div
key={payload.name}
className="flex cursor-pointer items-center justify-between rounded-lg pr-1 pl-[21px] hover:bg-state-base-hover"
onClick={() => {
if (disabled)
return
const params: Record<string, string> = {}
if (payload.parameters) {
payload.parameters.forEach((item: any) => {
params[item.name] = ''
})
}
onSelect(BlockEnum.TriggerPlugin, {
plugin_id: provider.plugin_id,
provider_id: provider.name,
provider_type: provider.type as string,
provider_name: provider.name,
event_name: payload.name,
event_label: payload.label[language]!,
event_description: payload.description[language]!,
plugin_unique_identifier: provider.plugin_unique_identifier,
title: payload.label[language]!,
is_team_authorization: provider.is_team_authorization,
output_schema: payload.output_schema || {},
paramSchemas: payload.parameters,
params,
meta: provider.meta,
})
}}
>
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary')}>
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
<div className="mr-4 system-xs-regular text-text-tertiary">{t('addToolModal.added', { ns: 'tools' })}</div>
)}
</div>
</Tooltip>
</PreviewCardContent>
</PreviewCard>
)
}
export default React.memo(TriggerPluginActionItem)

View File

@ -28,7 +28,7 @@ const createProps = (
readonly: false,
setControlFocus: vi.fn(),
setOpen: vi.fn(),
tooltipPopup: null,
hoverPopup: null,
triggerRef: { current: null },
value: [],
varKindType: VarKindType.constant,

View File

@ -1,12 +1,13 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { FC, ReactElement } from 'react'
import type { VarType as VarKindType } from '../../../tool/types'
import type { CredentialFormSchema, CredentialFormSchemaSelect } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { Node, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiArrowDownSLine, RiCloseLine, RiErrorWarningFill, RiLoader4Line, RiMoreLine } from '@remixicon/react'
import Badge from '@/app/components/base/badge'
@ -18,6 +19,10 @@ import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/com
import RemoveButton from '../remove-button'
import ConstantField from './constant-field'
export type HoverPopup
= | { kind: 'full-path', panel: ReactElement }
| { kind: 'invalid-variable', message: string }
type Props = {
className?: string
controlFocus: number
@ -53,7 +58,7 @@ type Props = {
setControlFocus: (value: number) => void
setOpen: (value: boolean) => void
showErrorIcon?: boolean
tooltipPopup: ReactNode
hoverPopup: HoverPopup | null
triggerRef: React.RefObject<HTMLDivElement | null>
type?: string
typePlaceHolder?: string
@ -99,7 +104,7 @@ const VarReferencePickerTrigger: FC<Props> = ({
setControlFocus,
setOpen,
showErrorIcon = false,
tooltipPopup,
hoverPopup,
triggerRef,
type,
typePlaceHolder,
@ -112,6 +117,101 @@ const VarReferencePickerTrigger: FC<Props> = ({
VarPickerWrap,
WrapElem,
}) => {
const pill = (
<div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}>
{hasValue
? (
<>
{isShowNodeName && (
<div
className="flex items-center"
onClick={(e) => {
if (e.metaKey || e.ctrlKey)
handleVariableJump(outputVarNodeId || '')
}}
>
<div className="h-3 px-px">
{'type' in (outputVarNode || {}) && outputVarNode?.type && (
<VarBlockIcon
type={outputVarNode.type}
className="text-text-primary"
/>
)}
</div>
<div
className="mx-0.5 truncate text-xs font-medium text-text-secondary"
title={outputVarNode?.title as string | undefined}
style={{ maxWidth: maxNodeNameWidth }}
>
{outputVarNode?.title as string | undefined}
</div>
<Line3 className="mr-0.5"></Line3>
</div>
)}
{isShowAPart && (
<div className="flex items-center">
<RiMoreLine className="h-3 w-3 text-text-secondary" />
<Line3 className="mr-0.5 text-divider-deep"></Line3>
</div>
)}
<div className="flex items-center text-text-accent">
{isLoading && <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />}
<VariableIconWithColor
variables={value as ValueSelector}
variableCategory={variableCategory}
isExceptionVariable={isException}
/>
<div
className={cn('ml-0.5 truncate text-xs font-medium', isException && 'text-text-warning')}
title={varName}
style={{ maxWidth: maxVarNameWidth }}
>
{varName}
</div>
</div>
<div
className="ml-0.5 truncate text-center system-xs-regular text-text-tertiary capitalize"
title={type}
style={{ maxWidth: maxTypeWidth }}
>
{type}
</div>
{showErrorIcon && <RiErrorWarningFill data-testid="var-reference-picker-error-icon" className="ml-0.5 h-3 w-3 text-text-destructive" />}
</>
)
: (
<div className={`overflow-hidden ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'} system-sm-regular text-ellipsis`}>
{isLoading
? (
<div className="flex items-center">
<RiLoader4Line className="mr-1 h-3.5 w-3.5 animate-spin text-text-secondary" />
<span>{placeholder}</span>
</div>
)
: placeholder}
</div>
)}
</div>
)
const hoveredPill = hoverPopup?.kind === 'full-path'
? (
<PreviewCard>
<PreviewCardTrigger delay={300} closeDelay={200} render={pill} />
<PreviewCardContent popupClassName="border-0 bg-transparent p-0 shadow-none">
{hoverPopup.panel}
</PreviewCardContent>
</PreviewCard>
)
: hoverPopup?.kind === 'invalid-variable'
? (
<Tooltip>
<TooltipTrigger render={pill} />
<TooltipContent>{hoverPopup.message}</TooltipContent>
</Tooltip>
)
: pill
return (
<WrapElem
onClick={() => {
@ -191,92 +291,7 @@ const VarReferencePickerTrigger: FC<Props> = ({
className="h-full grow"
>
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
<Tooltip>
<TooltipTrigger
disabled={!tooltipPopup}
render={(
<div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}>
{hasValue
? (
<>
{isShowNodeName && (
<div
className="flex items-center"
onClick={(e) => {
if (e.metaKey || e.ctrlKey)
handleVariableJump(outputVarNodeId || '')
}}
>
<div className="h-3 px-px">
{'type' in (outputVarNode || {}) && outputVarNode?.type && (
<VarBlockIcon
type={outputVarNode.type}
className="text-text-primary"
/>
)}
</div>
<div
className="mx-0.5 truncate text-xs font-medium text-text-secondary"
title={outputVarNode?.title as string | undefined}
style={{ maxWidth: maxNodeNameWidth }}
>
{outputVarNode?.title as string | undefined}
</div>
<Line3 className="mr-0.5"></Line3>
</div>
)}
{isShowAPart && (
<div className="flex items-center">
<RiMoreLine className="h-3 w-3 text-text-secondary" />
<Line3 className="mr-0.5 text-divider-deep"></Line3>
</div>
)}
<div className="flex items-center text-text-accent">
{isLoading && <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />}
<VariableIconWithColor
variables={value as ValueSelector}
variableCategory={variableCategory}
isExceptionVariable={isException}
/>
<div
className={cn('ml-0.5 truncate text-xs font-medium', isException && 'text-text-warning')}
title={varName}
style={{ maxWidth: maxVarNameWidth }}
>
{varName}
</div>
</div>
<div
className="ml-0.5 truncate text-center system-xs-regular text-text-tertiary capitalize"
title={type}
style={{ maxWidth: maxTypeWidth }}
>
{type}
</div>
{showErrorIcon && <RiErrorWarningFill data-testid="var-reference-picker-error-icon" className="ml-0.5 h-3 w-3 text-text-destructive" />}
</>
)
: (
<div className={`overflow-hidden ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'} system-sm-regular text-ellipsis`}>
{isLoading
? (
<div className="flex items-center">
<RiLoader4Line className="mr-1 h-3.5 w-3.5 animate-spin text-text-secondary" />
<span>{placeholder}</span>
</div>
)
: placeholder}
</div>
)}
</div>
)}
/>
{tooltipPopup !== null && tooltipPopup !== undefined && (
<TooltipContent variant="plain">
{tooltipPopup}
</TooltipContent>
)}
</Tooltip>
{hoveredPill}
</div>
</VarPickerWrap>

View File

@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
import type { HoverPopup } from './var-reference-picker.trigger'
import type { CredentialFormSchema, CredentialFormSchemaSelect, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
@ -291,20 +292,23 @@ const VarReferencePicker: FC<Props> = ({
const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const VarPickerWrap = !isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const tooltipPopup = useMemo(() => {
const hoverPopup = useMemo<HoverPopup | null>(() => {
const tooltipType = getTooltipContent(hasValue, isShowAPart, isValidVar)
if (tooltipType === 'full-path') {
return (
<VarFullPathPanel
nodeName={outputVarNode?.title}
path={(value as ValueSelector).slice(1)}
varType={varTypeToStructType(type)}
nodeType={outputVarNode?.type}
/>
)
return {
kind: 'full-path',
panel: (
<VarFullPathPanel
nodeName={outputVarNode?.title}
path={(value as ValueSelector).slice(1)}
varType={varTypeToStructType(type)}
nodeType={outputVarNode?.type}
/>
),
}
}
if (tooltipType === 'invalid-variable')
return t('errorMsg.invalidVariable', { ns: 'workflow' })
return { kind: 'invalid-variable', message: t('errorMsg.invalidVariable', { ns: 'workflow' }) }
return null
}, [isValidVar, isShowAPart, hasValue, t, outputVarNode?.title, outputVarNode?.type, value, type])
@ -389,7 +393,7 @@ const VarReferencePicker: FC<Props> = ({
setControlFocus={setControlFocus}
setOpen={setOpen}
showErrorIcon={showErrorIcon}
tooltipPopup={tooltipPopup}
hoverPopup={hoverPopup}
triggerRef={triggerRef}
type={type}
typePlaceHolder={typePlaceHolder}