dify
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import React from 'react'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '.'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('PortalToFollowElem', () => {
|
||||
describe('Context and Provider', () => {
|
||||
test('should throw error when using context outside provider', () => {
|
||||
// Suppress console.error for this test
|
||||
const originalError = console.error
|
||||
console.error = jest.fn()
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<PortalToFollowElemTrigger>Trigger</PortalToFollowElemTrigger>,
|
||||
)
|
||||
}).toThrow('PortalToFollowElem components must be wrapped in <PortalToFollowElem />')
|
||||
|
||||
console.error = originalError
|
||||
})
|
||||
|
||||
test('should not throw when used within provider', () => {
|
||||
expect(() => {
|
||||
render(
|
||||
<PortalToFollowElem>
|
||||
<PortalToFollowElemTrigger>Trigger</PortalToFollowElemTrigger>
|
||||
</PortalToFollowElem>,
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PortalToFollowElemTrigger', () => {
|
||||
test('should render children correctly', () => {
|
||||
const { getByText } = render(
|
||||
<PortalToFollowElem>
|
||||
<PortalToFollowElemTrigger>Trigger Text</PortalToFollowElemTrigger>
|
||||
</PortalToFollowElem>,
|
||||
)
|
||||
expect(getByText('Trigger Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should handle asChild prop correctly', () => {
|
||||
const { getByRole } = render(
|
||||
<PortalToFollowElem>
|
||||
<PortalToFollowElemTrigger asChild >
|
||||
<button>Button Trigger</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
</PortalToFollowElem>,
|
||||
)
|
||||
|
||||
expect(getByRole('button')).toHaveTextContent('Button Trigger')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PortalToFollowElemContent', () => {
|
||||
test('should not render content when closed', () => {
|
||||
const { queryByText } = render(
|
||||
<PortalToFollowElem open={false} >
|
||||
<PortalToFollowElemTrigger>Trigger</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>Popup Content</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>,
|
||||
)
|
||||
|
||||
expect(queryByText('Popup Content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should render content when open', () => {
|
||||
const { getByText } = render(
|
||||
<PortalToFollowElem open={true} >
|
||||
<PortalToFollowElemTrigger>Trigger </PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>Popup Content</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>,
|
||||
)
|
||||
|
||||
expect(getByText('Popup Content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Controlled behavior', () => {
|
||||
test('should call onOpenChange when interaction happens', () => {
|
||||
const handleOpenChange = jest.fn()
|
||||
|
||||
const { getByText } = render(
|
||||
<PortalToFollowElem onOpenChange={handleOpenChange} >
|
||||
<PortalToFollowElemTrigger>Hover Me</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>Content</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>,
|
||||
)
|
||||
|
||||
fireEvent.mouseEnter(getByText('Hover Me'))
|
||||
expect(handleOpenChange).toHaveBeenCalled()
|
||||
|
||||
fireEvent.mouseLeave(getByText('Hover Me'))
|
||||
expect(handleOpenChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Configuration options', () => {
|
||||
test('should accept placement prop', () => {
|
||||
// Since we can't easily test actual positioning, we'll check if the prop is passed correctly
|
||||
const useFloatingMock = jest.spyOn(require('@floating-ui/react'), 'useFloating')
|
||||
|
||||
render(
|
||||
<PortalToFollowElem placement='top-start' >
|
||||
<PortalToFollowElemTrigger>Trigger</PortalToFollowElemTrigger>
|
||||
</PortalToFollowElem>,
|
||||
)
|
||||
|
||||
expect(useFloatingMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: 'top-start',
|
||||
}),
|
||||
)
|
||||
|
||||
useFloatingMock.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '.'
|
||||
|
||||
const TooltipCard = ({ title, description }: { title: string; description: string }) => (
|
||||
<div className="w-[220px] rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-sm text-text-secondary shadow-lg">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-[0.14em] text-text-tertiary">
|
||||
{title}
|
||||
</div>
|
||||
<p className="leading-5">{description}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const PortalDemo = ({
|
||||
placement = 'bottom',
|
||||
triggerPopupSameWidth = false,
|
||||
}: {
|
||||
placement?: Parameters<typeof PortalToFollowElem>[0]['placement']
|
||||
triggerPopupSameWidth?: boolean
|
||||
}) => {
|
||||
const [controlledOpen, setControlledOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-3xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<PortalToFollowElem placement={placement} triggerPopupSameWidth={triggerPopupSameWidth}>
|
||||
<PortalToFollowElemTrigger className="rounded-md border border-divider-subtle bg-background-default px-3 py-2 text-sm text-text-secondary">
|
||||
Hover me
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-40">
|
||||
<TooltipCard
|
||||
title="Auto follow"
|
||||
description="The floating element repositions itself when the trigger moves, using Floating UI under the hood."
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
triggerPopupSameWidth
|
||||
open={controlledOpen}
|
||||
onOpenChange={setControlledOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default-subtle px-3 py-2 text-sm font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => setControlledOpen(prev => !prev)}
|
||||
>
|
||||
Controlled toggle
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-40">
|
||||
<TooltipCard
|
||||
title="Controlled"
|
||||
description="This panel uses the controlled API via onOpenChange/open props, and matches the trigger width."
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Feedback/PortalToFollowElem',
|
||||
component: PortalDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Floating UI based portal that tracks trigger positioning. Demonstrates both hover-driven and controlled usage.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
placement: {
|
||||
control: 'select',
|
||||
options: ['top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end'],
|
||||
},
|
||||
triggerPopupSameWidth: { control: 'boolean' },
|
||||
},
|
||||
args: {
|
||||
placement: 'bottom',
|
||||
triggerPopupSameWidth: false,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PortalDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const SameWidthPanel: Story = {
|
||||
args: {
|
||||
triggerPopupSameWidth: true,
|
||||
},
|
||||
}
|
||||
194
dify/web/app/components/base/portal-to-follow-elem/index.tsx
Normal file
194
dify/web/app/components/base/portal-to-follow-elem/index.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import {
|
||||
FloatingPortal,
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
size,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useFocus,
|
||||
useHover,
|
||||
useInteractions,
|
||||
useMergeRefs,
|
||||
useRole,
|
||||
} from '@floating-ui/react'
|
||||
|
||||
import type { OffsetOptions, Placement } from '@floating-ui/react'
|
||||
import cn from '@/utils/classnames'
|
||||
export type PortalToFollowElemOptions = {
|
||||
/*
|
||||
* top, bottom, left, right
|
||||
* start, end. Default is middle
|
||||
* combine: top-start, top-end
|
||||
*/
|
||||
placement?: Placement
|
||||
open?: boolean
|
||||
offset?: number | OffsetOptions
|
||||
onOpenChange?: (open: boolean) => void
|
||||
triggerPopupSameWidth?: boolean
|
||||
}
|
||||
|
||||
export function usePortalToFollowElem({
|
||||
placement = 'bottom',
|
||||
open: controlledOpen,
|
||||
offset: offsetValue = 0,
|
||||
onOpenChange: setControlledOpen,
|
||||
triggerPopupSameWidth,
|
||||
}: PortalToFollowElemOptions = {}) {
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
const open = controlledOpen ?? localOpen
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setLocalOpen(newOpen)
|
||||
setControlledOpen?.(newOpen)
|
||||
}, [setControlledOpen, setLocalOpen])
|
||||
|
||||
const data = useFloating({
|
||||
placement,
|
||||
open,
|
||||
onOpenChange: handleOpenChange,
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(offsetValue),
|
||||
flip({
|
||||
crossAxis: placement.includes('-'),
|
||||
fallbackAxisSideDirection: 'start',
|
||||
padding: 5,
|
||||
}),
|
||||
shift({ padding: 5 }),
|
||||
size({
|
||||
apply({ rects, elements }) {
|
||||
if (triggerPopupSameWidth)
|
||||
elements.floating.style.width = `${rects.reference.width}px`
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const context = data.context
|
||||
|
||||
const hover = useHover(context, {
|
||||
move: false,
|
||||
enabled: controlledOpen === undefined,
|
||||
})
|
||||
const focus = useFocus(context, {
|
||||
enabled: controlledOpen === undefined,
|
||||
})
|
||||
const dismiss = useDismiss(context)
|
||||
const role = useRole(context, { role: 'tooltip' })
|
||||
|
||||
const interactions = useInteractions([hover, focus, dismiss, role])
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
open,
|
||||
setOpen: handleOpenChange,
|
||||
...interactions,
|
||||
...data,
|
||||
}),
|
||||
[open, handleOpenChange, interactions, data],
|
||||
)
|
||||
}
|
||||
|
||||
type ContextType = ReturnType<typeof usePortalToFollowElem> | null
|
||||
|
||||
const PortalToFollowElemContext = React.createContext<ContextType>(null)
|
||||
|
||||
export function usePortalToFollowElemContext() {
|
||||
const context = React.useContext(PortalToFollowElemContext)
|
||||
|
||||
if (context == null)
|
||||
throw new Error('PortalToFollowElem components must be wrapped in <PortalToFollowElem />')
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export function PortalToFollowElem({
|
||||
children,
|
||||
...options
|
||||
}: { children: React.ReactNode } & PortalToFollowElemOptions) {
|
||||
// This can accept any props as options, e.g. `placement`,
|
||||
// or other positioning options.
|
||||
const tooltip = usePortalToFollowElem(options)
|
||||
return (
|
||||
<PortalToFollowElemContext.Provider value={tooltip}>
|
||||
{children}
|
||||
</PortalToFollowElemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const PortalToFollowElemTrigger = (
|
||||
{
|
||||
ref: propRef,
|
||||
children,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.HTMLProps<HTMLElement> & { ref?: React.RefObject<HTMLElement | null>, asChild?: boolean },
|
||||
) => {
|
||||
const context = usePortalToFollowElemContext()
|
||||
const childrenRef = (children as any).props?.ref
|
||||
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
|
||||
|
||||
// `asChild` allows the user to pass any element as the anchor
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const childProps = (children.props ?? {}) as Record<string, unknown>
|
||||
return React.cloneElement(
|
||||
children,
|
||||
context.getReferenceProps({
|
||||
ref,
|
||||
...props,
|
||||
...childProps,
|
||||
'data-state': context.open ? 'open' : 'closed',
|
||||
} as React.HTMLProps<HTMLElement>),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('inline-block', props.className)}
|
||||
// The user can style the trigger based on the state
|
||||
data-state={context.open ? 'open' : 'closed'}
|
||||
{...context.getReferenceProps(props)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
|
||||
|
||||
export const PortalToFollowElemContent = (
|
||||
{
|
||||
ref: propRef,
|
||||
style,
|
||||
...props
|
||||
}: React.HTMLProps<HTMLDivElement> & {
|
||||
ref?: React.RefObject<HTMLDivElement | null>;
|
||||
},
|
||||
) => {
|
||||
const context = usePortalToFollowElemContext()
|
||||
const ref = useMergeRefs([context.refs.setFloating, propRef])
|
||||
|
||||
if (!context.open)
|
||||
return null
|
||||
|
||||
const body = document.body
|
||||
|
||||
return (
|
||||
<FloatingPortal root={body}>
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...context.floatingStyles,
|
||||
...style,
|
||||
visibility: context.middlewareData.hide?.referenceHidden ? 'hidden' : 'visible',
|
||||
}}
|
||||
{...context.getFloatingProps(props)}
|
||||
/>
|
||||
</FloatingPortal>
|
||||
)
|
||||
}
|
||||
|
||||
PortalToFollowElemContent.displayName = 'PortalToFollowElemContent'
|
||||
Reference in New Issue
Block a user