From c2a59620237dae3e25199387fadc128a51a18401 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:05:22 +0800 Subject: [PATCH] feat(dify-ui): add PreviewCard primitive (#35434) Signed-off-by: yyh Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .gitignore | 4 + eslint-suppressions.json | 29 --- packages/dify-ui/AGENTS.md | 23 ++ packages/dify-ui/package.json | 4 + .../dify-ui/src/popover/index.stories.tsx | 44 +++- .../src/preview-card/__tests__/index.spec.tsx | 127 +++++++++++ .../src/preview-card/index.stories.tsx | 213 ++++++++++++++++++ packages/dify-ui/src/preview-card/index.tsx | 81 +++++++ .../src/tooltip/__tests__/index.spec.tsx | 21 +- .../dify-ui/src/tooltip/index.stories.tsx | 209 ++++++----------- packages/dify-ui/src/tooltip/index.tsx | 31 ++- web/app/components/base/infotip/index.tsx | 82 +++++++ .../__tests__/variable-block.spec.tsx | 11 +- .../hitl-input-block/variable-block.tsx | 17 +- .../workflow-variable-block/component.tsx | 14 +- .../model-parameter-modal/parameter-item.tsx | 21 +- .../model-selector/popup-item.tsx | 24 +- .../model-selector/popup.tsx | 2 +- .../usage-priority-section.tsx | 19 +- .../provider-added-card/quota-panel.tsx | 17 +- .../system-model-selector/index.tsx | 28 +-- .../workflow/block-selector/blocks.tsx | 69 +++--- .../block-selector/featured-tools.tsx | 117 +++++----- .../block-selector/featured-triggers.tsx | 117 +++++----- .../workflow/block-selector/start-blocks.tsx | 63 +++--- .../block-selector/tool/action-item.tsx | 107 ++++----- .../trigger-plugin/action-item.tsx | 97 ++++---- .../var-reference-picker.trigger.spec.tsx | 2 +- .../variable/var-reference-picker.trigger.tsx | 193 ++++++++-------- .../variable/var-reference-picker.tsx | 26 ++- 30 files changed, 1151 insertions(+), 661 deletions(-) create mode 100644 packages/dify-ui/src/preview-card/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/preview-card/index.stories.tsx create mode 100644 packages/dify-ui/src/preview-card/index.tsx create mode 100644 web/app/components/base/infotip/index.tsx diff --git a/.gitignore b/.gitignore index 3493a7c756..836bddbb49 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 96af36d27a..b4840a2eff 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 } diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md index 651b117070..4a7fe2f22a 100644 --- a/packages/dify-ui/AGENTS.md +++ b/packages/dify-ui/AGENTS.md @@ -11,6 +11,27 @@ Shared design tokens, the `cn()` utility, a Tailwind CSS preset, and headless pr - Props pattern: `Omit & VariantProps & { /* 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 diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index e1b7a3c1ef..408ba2c432 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -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" diff --git a/packages/dify-ui/src/popover/index.stories.tsx b/packages/dify-ui/src/popover/index.stories.tsx index dcea5018ab..802337634b 100644 --- a/packages/dify-ui/src/popover/index.stories.tsx +++ b/packages/dify-ui/src/popover/index.stories.tsx @@ -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` (`{helpText}`) which wraps this pattern with consistent delays (300/200), typography, and `aria-label` plumbing.', + ].join('\n'), + }, + }, + }, + render: () => ( +
+ Usage priority + + + + + )} + /> + + Set which resource to use first when running models. The Trial quota will be used after the paid quota is exhausted. + + +
+ ), +} + const PLACEMENTS: Placement[] = [ 'top-start', 'top', diff --git a/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx b/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx new file mode 100644 index 0000000000..5d1e325051 --- /dev/null +++ b/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx @@ -0,0 +1,127 @@ +import { render } from 'vitest-browser-react' +import { + PreviewCard, + PreviewCardContent, + PreviewCardTrigger, +} from '..' + +const renderWithSafeViewport = (ui: import('react').ReactNode) => render( +
+ {ui} +
, +) + +describe('PreviewCardContent', () => { + describe('Placement', () => { + it('should use bottom placement and default offsets when placement props are not provided', async () => { + const screen = await renderWithSafeViewport( + + Open} + /> + + Default content + + , + ) + + 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( + + Open} + /> + + Custom placement content + + , + ) + + 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( + + Open} + /> + + Preview body + + , + ) + + 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( + + + Open + + )} + /> + + Preview body + + , + ) + + const trigger = screen.getByRole('button', { name: 'preview trigger' }) + await trigger.click() + + expect(onPrimaryClick).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/dify-ui/src/preview-card/index.stories.tsx b/packages/dify-ui/src/preview-card/index.stories.tsx new file mode 100644 index 0000000000..540ac08c1a --- /dev/null +++ b/packages/dify-ui/src/preview-card/index.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +// --- Canonical: inline link preview --------------------------------------- +// Mirrors Base UI's own PreviewCard docs demo: an inline `` 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 `` — click still follows the link; the preview is strictly supplementary.', + }, + }, + }, + render: () => ( +
+

+ The principles of good + {' '} + + typography + + {' '} + remain in the digital age. +

+ + + +
+ Station Hofplein signage in Rotterdam, Netherlands +

+ Typography + {' '} + is the art and science of arranging type to make written language legible, readable, and visually appealing. +

+
+
+
+
+ ), +} + +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 `
+ )} + /> + +
+
gpt-4o
+
+ Multimodal flagship model. Vision, audio and 128k context. +
+
+
+ + ), +} + +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('bottom') + + return ( +
+
+ {PLACEMENTS.map(value => ( + + ))} +
+ + Hover me} + /> + +
+
+ placement=" + {placement} + " +
+
+ Preview positions itself relative to the trigger. +
+
+
+
+
+ ) +} + +export const Placements: Story = { + parameters: { + layout: 'fullscreen', + }, + render: () => , +} + +const CustomDelayDemo = () => ( + + Snappy trigger} + /> + +
+
Fast hover
+
+ Base UI defaults (600ms / 300ms) are tuned for link previews. Override per trigger for denser UIs. +
+
+
+
+) + +export const CustomDelays: Story = { + render: () => , +} diff --git a/packages/dify-ui/src/preview-card/index.tsx b/packages/dify-ui/src/preview-card/index.tsx new file mode 100644 index 0000000000..771b15cf13 --- /dev/null +++ b/packages/dify-ui/src/preview-card/index.tsx @@ -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 ( + + + + {children} + + + + ) +} diff --git a/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx b/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx index 3660c2c8e5..043835f697 100644 --- a/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx @@ -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( - - Trigger - - Plain tooltip body - - , - ) - - 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 () => { diff --git a/packages/dify-ui/src/tooltip/index.stories.tsx b/packages/dify-ui/src/tooltip/index.stories.tsx index dca3be32f3..902449d4a4 100644 --- a/packages/dify-ui/src/tooltip/index.stories.tsx +++ b/packages/dify-ui/src/tooltip/index.stories.tsx @@ -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 -export const Default: Story = { - render: () => ( - - } - > - Hover me - - - Tooltips describe interactive elements without a click. - - - ), -} +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: () => ( +
+ {ICON_ACTIONS.map(({ icon, label }) => ( + + + + + )} + /> + {label} + + ))} +
+ ), +} + +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: () => ( } - > - Preview details - - -
- Dataset preview - - 32 documents • Last indexed 2 minutes ago - -
-
+ render={( + + )} + /> + ⌘S
), } @@ -116,14 +127,10 @@ const PlacementsDemo = () => { } - > - Anchor - + render={} + /> - placement=" - {placement} - " + {`placement="${placement}"`} @@ -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: () => , } -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: () => ( -
- - - - - )} - /> - Edit - - - - - - )} - /> - Duplicate - - - - - - )} - /> - Archive - - - - - - )} - /> - Delete - -
- ), -} - -export const LongContent: Story = { - render: () => ( - - } - > - What are tokens? - - - 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. - - - ), -} - 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 ( -
- {DELAY_PRESETS.map(({ label, delay }) => ( - - - } - > - {label} - - - Appeared after - {delay} - ms hover delay. - - - - ))} -
- ) -} +const DelayDemo = () => ( +
+ {DELAY_PRESETS.map(({ label, delay }) => ( + + + + + + )} + /> + {`${label} (${delay}ms)`} + + + ))} +
+) 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.', }, }, }, diff --git a/packages/dify-ui/src/tooltip/index.tsx b/packages/dify-ui/src/tooltip/index.tsx index e0fcd7c5c3..1f9772ce2d 100644 --- a/packages/dify-ui/src/tooltip/index.tsx +++ b/packages/dify-ui/src/tooltip/index.tsx @@ -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 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({ > ) } - -export const TooltipProvider = BaseTooltip.Provider -export const Tooltip = BaseTooltip.Root -export const TooltipTrigger = BaseTooltip.Trigger diff --git a/web/app/components/base/infotip/index.tsx b/web/app/components/base/infotip/index.tsx new file mode 100644 index 0000000000..b97b499af3 --- /dev/null +++ b/web/app/components/base/infotip/index.tsx @@ -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 ( + + + + + )} + /> + + {children} + + + ) +} diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/variable-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/variable-block.spec.tsx index db3e474b60..b0338cb823 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/variable-block.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/variable-block.spec.tsx @@ -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', () => { diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx index 44303c8faa..913d5039c1 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx @@ -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 ( - + {Item}} /> + - )} - disabled={!isShowAPart} - > -
{Item}
-
+ + ) } diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx index ecd5183a14..cb9178f36b 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx @@ -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 ( - - {Item}} /> - + + {Item}} /> + - - + + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx index f7e1962fd7..733b299664 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx @@ -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({ { parameterRule.help && ( - - - - - )} - /> - -
{parameterRule.help[language] || parameterRule.help.en_US}
-
-
+ + {parameterRule.help[language] || parameterRule.help.en_US} + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx index ed16cd7904..72c52a9429 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx @@ -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 = ({ {!collapsed && model.models.map(modelItem => ( - - + = ({ )} /> -
@@ -245,8 +253,8 @@ const PopupItem: FC = ({
)}
-
-
+ + ))} ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx index ec07b6c114..47ddb55b6c 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx @@ -225,7 +225,7 @@ const Popup: FC = ({ {showCreditsExhaustedAlert && ( )} -
+
{ filteredModelList.map(model => ( @@ -31,19 +32,9 @@ export default function UsagePrioritySection({ value, disabled, onSelect }: Usag {t('modelProvider.card.usagePriority', { ns: 'common' })} - - - - - )} - /> - - {t('modelProvider.card.usagePriorityTip', { ns: 'common' })} - - + + {usagePriorityTip} +
{options.map(option => ( diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx index bbc2cfaa98..8de8167dc2 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx @@ -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 = ({
{t('modelProvider.quota', { ns: 'common' })} - - - - - )} - /> - - {tipText} - - + + {tipText} +
diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx index 9f993260ca..8ceaaaa32e 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx @@ -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 = ({ return (
{t(labelKey, { ns: 'common' })} - - - - - )} - /> - -
- {tipText} -
-
-
+ + {tipText} +
) } diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx index 7179a7c458..8258e4d450 100644 --- a/web/app/components/workflow/block-selector/blocks.tsx +++ b/web/app/components/workflow/block-selector/blocks.tsx @@ -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 => ( - + onSelect(block.metaData.type)} + > + +
{block.metaData.title}
+ { + block.metaData.type === BlockEnum.LoopEnd && ( + + ) + } +
+ )} + /> +
{block.metaData.title}
{block.metaData.description}
- )} - > -
onSelect(block.metaData.type)} - > - -
{block.metaData.title}
- { - block.metaData.type === BlockEnum.LoopEnd && ( - - ) - } -
- + + )) }
diff --git a/web/app/components/workflow/block-selector/featured-tools.tsx b/web/app/components/workflow/block-selector/featured-tools.tsx index 68c6d63a52..0cdeebcb79 100644 --- a/web/app/components/workflow/block-selector/featured-tools.tsx +++ b/web/app/components/workflow/block-selector/featured-tools.tsx @@ -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 = ( +
+
+ +
+
{label}
+
+
+
+ {installCountLabel} +
+ + +
+
+
+ ) + return ( <> - - -
{label}
-
{description}
-
- )} - disabled={!description || isActionHovered || actionOpen || isInstallModalOpen} - > -
-
- -
-
{label}
-
-
-
- {installCountLabel} -
setIsActionHovered(true)} - onMouseLeave={() => { - if (!actionOpen) - setIsActionHovered(false) - }} - > - - { - setActionOpen(value) - setIsActionHovered(value) - }} - author={plugin.org} - name={plugin.name} - version={plugin.latest_version} - /> -
-
-
- + {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. + + + +
+ +
{label}
+
{description}
+
+
+
+ ) + : row} {isInstallModalOpen && ( { setIsInstallModalOpen(false) - setIsActionHovered(false) await onInstallSuccess?.() }} onClose={() => { setIsInstallModalOpen(false) - setIsActionHovered(false) }} /> )} diff --git a/web/app/components/workflow/block-selector/featured-triggers.tsx b/web/app/components/workflow/block-selector/featured-triggers.tsx index c7c83410a6..3d3cdee2b7 100644 --- a/web/app/components/workflow/block-selector/featured-triggers.tsx +++ b/web/app/components/workflow/block-selector/featured-triggers.tsx @@ -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 = ( +
+
+ +
+
{label}
+
+
+
+ {installCountLabel} +
+ + +
+
+
+ ) + return ( <> - - -
{label}
-
{description}
-
- )} - disabled={!description || isActionHovered || actionOpen || isInstallModalOpen} - > -
-
- -
-
{label}
-
-
-
- {installCountLabel} -
setIsActionHovered(true)} - onMouseLeave={() => { - if (!actionOpen) - setIsActionHovered(false) - }} - > - - { - setActionOpen(value) - setIsActionHovered(value) - }} - author={plugin.org} - name={plugin.name} - version={plugin.latest_version} - /> -
-
-
- + {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. + + + +
+ +
{label}
+
{description}
+
+
+
+ ) + : row} {isInstallModalOpen && ( { setIsInstallModalOpen(false) - setIsActionHovered(false) await onInstallSuccess?.() }} onClose={() => { setIsInstallModalOpen(false) - setIsActionHovered(false) }} /> )} diff --git a/web/app/components/workflow/block-selector/start-blocks.tsx b/web/app/components/workflow/block-selector/start-blocks.tsx index ef332844d5..efc1e5c1b9 100644 --- a/web/app/components/workflow/block-selector/start-blocks.tsx +++ b/web/app/components/workflow/block-selector/start-blocks.tsx @@ -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]) => ( - + onSelect(block.type)} + > + +
+ {t(`blocks.${block.type}`, { ns: 'workflow' })} + {block.type === BlockEnumValues.Start && ( + {t('blocks.originalStartNode', { ns: 'workflow' })} + )} +
+ + )} + /> +
)}
- )} - > -
onSelect(block.type)} - > - -
- {t(`blocks.${block.type}`, { ns: 'workflow' })} - {block.type === BlockEnumValues.Start && ( - {t('blocks.originalStartNode', { ns: 'workflow' })} - )} -
-
-
- ), [availableNodesMetaData, onSelect, t]) + + + ), [onSelect, t]) if (isEmpty) return null diff --git a/web/app/components/workflow/block-selector/tool/action-item.tsx b/web/app/components/workflow/block-selector/tool/action-item.tsx index 4afe1b1da3..343f8482df 100644 --- a/web/app/components/workflow/block-selector/tool/action-item.tsx +++ b/web/app/components/workflow/block-selector/tool/action-item.tsx @@ -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 = ({ return normalizedIcon }, [theme, normalizedIcon, normalizedIconDark]) - return ( - { + if (disabled) + return + const params: Record = {} + 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, + }) + }} + > +
+ {payload.label[language]} +
+ {isAdded && ( +
{t('addToolModal.added', { ns: 'tools' })}
+ )} + + ) + + 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. + + +
= ({
{payload.label[language]}
{payload.description[language]}
- )} - > -
{ - if (disabled) - return - const params: Record = {} - 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, - }) - }} - > -
- {payload.label[language]} -
- {isAdded && ( -
{t('addToolModal.added', { ns: 'tools' })}
- )} -
-
+ + ) } export default React.memo(ToolItem) diff --git a/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx b/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx index 4ffb84e1b8..38c4c2b0f5 100644 --- a/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx +++ b/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx @@ -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 = ({ const { t } = useTranslation() const language = useGetLanguage() - return ( - { + if (disabled) + return + const params: Record = {} + 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, + }) + }} + > +
+ {payload.label[language]} +
+ {isAdded && ( +
{t('addToolModal.added', { ns: 'tools' })}
+ )} + + ) + + 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. + + +
= ({
{payload.label[language]}
{payload.description[language]}
- )} - > -
{ - if (disabled) - return - const params: Record = {} - 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, - }) - }} - > -
- {payload.label[language]} -
- {isAdded && ( -
{t('addToolModal.added', { ns: 'tools' })}
- )} -
-
+ + ) } export default React.memo(TriggerPluginActionItem) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx index f7264bcc99..c401377450 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx @@ -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, diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx index 8c46118615..5a0362a07e 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx @@ -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 type?: string typePlaceHolder?: string @@ -99,7 +104,7 @@ const VarReferencePickerTrigger: FC = ({ setControlFocus, setOpen, showErrorIcon = false, - tooltipPopup, + hoverPopup, triggerRef, type, typePlaceHolder, @@ -112,6 +117,101 @@ const VarReferencePickerTrigger: FC = ({ VarPickerWrap, WrapElem, }) => { + const pill = ( +
+ {hasValue + ? ( + <> + {isShowNodeName && ( +
{ + if (e.metaKey || e.ctrlKey) + handleVariableJump(outputVarNodeId || '') + }} + > +
+ {'type' in (outputVarNode || {}) && outputVarNode?.type && ( + + )} +
+
+ {outputVarNode?.title as string | undefined} +
+ +
+ )} + {isShowAPart && ( +
+ + +
+ )} +
+ {isLoading && } + +
+ {varName} +
+
+
+ {type} +
+ {showErrorIcon && } + + ) + : ( +
+ {isLoading + ? ( +
+ + {placeholder} +
+ ) + : placeholder} +
+ )} +
+ ) + + const hoveredPill = hoverPopup?.kind === 'full-path' + ? ( + + + + {hoverPopup.panel} + + + ) + : hoverPopup?.kind === 'invalid-variable' + ? ( + + + {hoverPopup.message} + + ) + : pill + return ( { @@ -191,92 +291,7 @@ const VarReferencePickerTrigger: FC = ({ className="h-full grow" >
- - - {hasValue - ? ( - <> - {isShowNodeName && ( -
{ - if (e.metaKey || e.ctrlKey) - handleVariableJump(outputVarNodeId || '') - }} - > -
- {'type' in (outputVarNode || {}) && outputVarNode?.type && ( - - )} -
-
- {outputVarNode?.title as string | undefined} -
- -
- )} - {isShowAPart && ( -
- - -
- )} -
- {isLoading && } - -
- {varName} -
-
-
- {type} -
- {showErrorIcon && } - - ) - : ( -
- {isLoading - ? ( -
- - {placeholder} -
- ) - : placeholder} -
- )} -
- )} - /> - {tooltipPopup !== null && tooltipPopup !== undefined && ( - - {tooltipPopup} - - )} - + {hoveredPill} diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 1cfccf1622..9b985c8a9e 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -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 = ({ const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger const VarPickerWrap = !isSupportConstantValue ? 'div' : PortalToFollowElemTrigger - const tooltipPopup = useMemo(() => { + const hoverPopup = useMemo(() => { const tooltipType = getTooltipContent(hasValue, isShowAPart, isValidVar) if (tooltipType === 'full-path') { - return ( - - ) + return { + kind: 'full-path', + panel: ( + + ), + } } 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 = ({ setControlFocus={setControlFocus} setOpen={setOpen} showErrorIcon={showErrorIcon} - tooltipPopup={tooltipPopup} + hoverPopup={hoverPopup} triggerRef={triggerRef} type={type} typePlaceHolder={typePlaceHolder}