This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -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()
})
})
})

View File

@@ -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,
},
}

View 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'