mirror of
https://github.com/langgenius/dify.git
synced 2026-06-17 06:21:07 +08:00
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:
parent
d583b1b835
commit
c2a5962023
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
127
packages/dify-ui/src/preview-card/__tests__/index.spec.tsx
Normal file
127
packages/dify-ui/src/preview-card/__tests__/index.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
213
packages/dify-ui/src/preview-card/index.stories.tsx
Normal file
213
packages/dify-ui/src/preview-card/index.stories.tsx
Normal 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 />,
|
||||
}
|
||||
81
packages/dify-ui/src/preview-card/index.tsx
Normal file
81
packages/dify-ui/src/preview-card/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 () => {
|
||||
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
82
web/app/components/base/infotip/index.tsx
Normal file
82
web/app/components/base/infotip/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 => (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user