refactor(ui): expose compound Infotip primitives and migrate consumers (#42681)

This commit is contained in:
yyh authored and GitHub committed 2026-09-22 03:09:18 +00:00
1 parent 206cf8fb94
commit bfcd0fdb39
94 files changed
+1329 -787

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ Import `styles.css` once from the consumer's root stylesheet or entrypoint.
| Layout | `./scroll-area`, `./separator` |
| Media | `./avatar` |
| Navigation | `./breadcrumb`, `./file-tree`, `./pagination`, `./tabs` |
| Overlay and menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` |
| Overlay and menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./infotip`, `./popover`, `./preview-card`, `./tooltip` |
| Search and pick | `./autocomplete`, `./combobox`, `./select` |
Utilities:
+4
View File
@@ -172,6 +172,10 @@
"./separator": {
"types": "./src/separator/index.tsx",
"import": "./src/separator/index.tsx"
},
"./infotip": {
"types": "./src/infotip/index.tsx",
"import": "./src/infotip/index.tsx"
}
},
"scripts": {
@@ -0,0 +1,47 @@
import * as React from 'react'
import { createPortal } from 'react-dom'
import { render } from 'vitest-browser-react'
import { Infotip, InfotipContent, InfotipTrigger } from '..'
describe('Infotip composition', () => {
it('preserves caller handlers and prevents an enclosing action from firing', async () => {
const parentClick = vi.fn()
const triggerClick = vi.fn()
const triggerRef = React.createRef<HTMLButtonElement>()
const screen = await render(
<button type="button" onClick={parentClick}>
Parent action
{createPortal(
<Infotip>
<InfotipTrigger aria-label="Details" onClick={triggerClick} ref={triggerRef} />
<InfotipContent aria-label="Details">Explanation</InfotipContent>
</Infotip>,
document.body,
)}
</button>,
)
await screen.getByRole('button', { name: 'Details' }).click()
await expect
.element(screen.getByRole('dialog', { name: 'Details' }))
.toHaveTextContent('Explanation')
expect(triggerRef.current?.tagName).toBe('BUTTON')
expect(triggerClick).toHaveBeenCalledOnce()
expect(parentClick).not.toHaveBeenCalled()
})
it('lets a caller cancel opening without losing trigger event composition', async () => {
const onOpenChange = vi.fn()
const screen = await render(
<Infotip onOpenChange={onOpenChange}>
<InfotipTrigger
aria-label="Unavailable details"
openOnHover={false}
onClick={(event) => event.preventBaseUIHandler()}
/>
<InfotipContent aria-label="Details">Explanation</InfotipContent>
</Infotip>,
)
await screen.getByRole('button', { name: 'Unavailable details' }).click()
expect(onOpenChange).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,47 @@
import type * as React from 'react'
import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip'
export function infotipTypeContracts(
triggerRef: React.Ref<HTMLButtonElement>,
popupRef: React.Ref<HTMLDivElement>,
) {
const composition = (
<Infotip<{ title: string }>>
{({ payload }) => (
<>
<InfotipTrigger
aria-label="Details"
ref={triggerRef}
payload={{ title: 'Details' }}
iconVariant="information"
iconSize="small"
className={(state) => (state.open ? 'text-text-accent' : undefined)}
style={(state) => ({ opacity: state.disabled ? 0.5 : 1 })}
/>
<InfotipContent
ref={popupRef}
aria-label="Details"
placement="bottom-start"
sideOffset={({ side }) => (side === 'top' ? 4 : 8)}
className={(state) => (state.open ? 'w-60' : undefined)}
style={(state) => ({ opacity: state.open ? 1 : 0 })}
>
{payload?.title}
</InfotipContent>
</>
)}
</Infotip>
)
const labelledTrigger = <InfotipTrigger aria-labelledby="visible-label" />
// @ts-expect-error An icon-only trigger requires an accessible name.
const unnamed = <InfotipTrigger />
// @ts-expect-error The icon is owned by InfotipTrigger.
const children = <InfotipTrigger aria-label="Details">Custom trigger</InfotipTrigger>
// @ts-expect-error InfotipTrigger always renders a native button.
const render = <InfotipTrigger aria-label="Details" render={<div />} />
const state = (
// @ts-expect-error Popup state is separate from trigger state.
<InfotipContent className={(state) => String(state.disabled)}>Details</InfotipContent>
)
return { composition, labelledTrigger, unnamed, children, render, state }
}
@@ -0,0 +1,105 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, userEvent, waitFor, within } from 'storybook/test'
import { Infotip, InfotipContent, InfotipTrigger } from '.'
import { Button } from '../button'
const meta = {
title: 'Base/UI/Infotip',
component: Infotip,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'An information button that opens explanatory content using Popover semantics. Compose the root, icon trigger, and content. Name the trigger and the dialog; use aria-labelledby when a visible heading exists. The popup remains available to keyboard and touch users and may contain links.',
},
},
},
} satisfies Meta<typeof Infotip>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<div className="flex items-center gap-1 text-text-secondary">
Document processing
<Infotip>
<InfotipTrigger aria-label="Document processing" />
<InfotipContent aria-label="Document processing">
Documents are processed in the order they are received. Larger files can take longer to
finish indexing.
</InfotipContent>
</Infotip>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const body = within(canvasElement.ownerDocument.body)
const trigger = canvas.getByRole('button', { name: 'Document processing' })
await userEvent.hover(trigger)
const popup = await body.findByRole('dialog', { name: 'Document processing' })
await userEvent.hover(popup)
await waitFor(() => expect(popup).toBeVisible())
await userEvent.keyboard('{Escape}')
await waitFor(() => expect(body.queryByRole('dialog')).not.toBeInTheDocument())
},
}
export const WithLink: Story = {
render: () => (
<Infotip>
<InfotipTrigger aria-label="Agent instructions" iconVariant="information" />
<InfotipContent aria-label="Agent instructions" className="w-60">
Describe the agent’s task and the rules it should follow.{' '}
<a href="https://docs.dify.ai" className="text-text-accent underline">
Read the documentation
</a>
</InfotipContent>
</Infotip>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const body = within(canvasElement.ownerDocument.body)
const trigger = canvas.getByRole('button', { name: 'Agent instructions' })
trigger.focus()
await userEvent.keyboard('{Enter}')
const popup = await body.findByRole('dialog', { name: 'Agent instructions' })
await waitFor(() =>
expect(within(popup).getByRole('link', { name: 'Read the documentation' })).toHaveFocus(),
)
await userEvent.keyboard('{Escape}')
await waitFor(() => expect(body.queryByRole('dialog')).not.toBeInTheDocument())
expect(trigger).toHaveFocus()
},
}
function ControlledExample() {
const [open, setOpen] = React.useState(false)
return (
<div className="flex items-center gap-3">
<Button onClick={() => setOpen(true)}>Show explanation</Button>
<Infotip open={open} onOpenChange={setOpen}>
<InfotipTrigger aria-label="Processing error" iconVariant="warning" />
<InfotipContent aria-label="Processing error">
The file could not be processed. Review its contents before trying again.
</InfotipContent>
</Infotip>
</div>
)
}
export const Controlled: Story = {
render: () => <ControlledExample />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const body = within(canvasElement.ownerDocument.body)
await userEvent.click(canvas.getByRole('button', { name: 'Show explanation' }))
await waitFor(() =>
expect(body.getByRole('dialog', { name: 'Processing error' })).toBeVisible(),
)
await userEvent.keyboard('{Escape}')
await waitFor(() => expect(body.queryByRole('dialog')).not.toBeInTheDocument())
},
}
+112
View File
@@ -0,0 +1,112 @@
'use client'
import type * as React from 'react'
import type { Placement } from '../placement'
import { Popover as BasePopover } from '@base-ui/react/popover'
import { cn } from '../cn'
import { iconButtonVariants } from '../icon-button'
import { resolveClassName } from '../internals/resolve-class-name'
import { floatingPopupAnimationClassName } from '../overlay-shared'
import { parsePlacement } from '../placement'
const iconClassNames = {
question: 'i-ri-question-line',
information: 'i-ri-information-line',
warning: 'i-ri-error-warning-line',
} as const
const iconSizeClassNames = {
small: 'size-3',
medium: 'size-3.5',
large: 'size-4',
} as const
const Infotip = BasePopover.Root
type InfotipProps<Payload = unknown> = BasePopover.Root.Props<Payload>
type InfotipTriggerProps<Payload = unknown> = Omit<
BasePopover.Trigger.Props<Payload>,
'children' | 'render' | 'nativeButton' | 'aria-label' | 'aria-labelledby'
> &
(
| { 'aria-label': string; 'aria-labelledby'?: never }
| { 'aria-label'?: never; 'aria-labelledby': string }
) & {
iconVariant?: keyof typeof iconClassNames
iconSize?: keyof typeof iconSizeClassNames
}
function InfotipTrigger<Payload = unknown>({
className,
iconVariant = 'question',
iconSize = 'medium',
openOnHover = true,
delay = 300,
closeDelay = 200,
onClick,
...props
}: InfotipTriggerProps<Payload>) {
return (
<BasePopover.Trigger
{...props}
openOnHover={openOnHover}
delay={delay}
closeDelay={closeDelay}
onClick={(event) => {
event.stopPropagation()
onClick?.(event)
}}
className={(state) =>
cn(
iconButtonVariants({ variant: null, size: 'xs' }),
'shrink-0 border-0 bg-transparent text-text-quaternary outline-hidden hover:text-text-tertiary',
resolveClassName(className, state),
)
}
>
<span aria-hidden className={cn(iconClassNames[iconVariant], iconSizeClassNames[iconSize])} />
</BasePopover.Trigger>
)
}
type InfotipContentProps = Omit<BasePopover.Popup.Props, 'children'> &
Pick<BasePopover.Positioner.Props, 'sideOffset' | 'alignOffset'> & {
children: React.ReactNode
placement?: Placement
}
function InfotipContent({
placement = 'top',
sideOffset = 8,
alignOffset = 0,
className,
...props
}: InfotipContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BasePopover.Portal>
<BasePopover.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className="z-50 outline-hidden"
>
<BasePopover.Popup
{...props}
className={(state) =>
cn(
'outline-hidden focus:outline-hidden focus-visible:outline-hidden',
floatingPopupAnimationClassName,
'max-w-75 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-3 py-2 system-xs-regular text-text-secondary shadow-[0px_12px_16px_-4px_var(--color-shadow-shadow-5),0px_4px_6px_-2px_var(--color-shadow-shadow-1)] backdrop-blur-[5px]',
resolveClassName(className, state),
)
}
/>
</BasePopover.Positioner>
</BasePopover.Portal>
)
}
export { Infotip, InfotipContent, InfotipTrigger }
export type { InfotipContentProps, InfotipProps, InfotipTriggerProps }