mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
refactor(ui): expose compound Infotip primitives and migrate consumers (#42681)
This commit is contained in:
94 files changed
+1329
-787
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user