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,120 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import CustomPopover from '.'
type PopoverContentProps = {
open?: boolean
onClose?: () => void
onClick?: () => void
title: string
description: string
}
const PopoverContent = ({ title, description, onClose }: PopoverContentProps) => {
return (
<div className="flex min-w-[220px] flex-col gap-2 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-text-tertiary">
{title}
</div>
<p className="text-sm leading-5 text-text-secondary">{description}</p>
<button
type="button"
className="self-start rounded-md border border-divider-subtle px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover"
onClick={onClose}
>
Dismiss
</button>
</div>
)
}
const Template = ({
trigger = 'hover',
position = 'bottom',
manualClose,
disabled,
}: {
trigger?: 'click' | 'hover'
position?: 'bottom' | 'bl' | 'br'
manualClose?: boolean
disabled?: boolean
}) => {
const [hoverHint] = useState(
trigger === 'hover'
? 'Hover over the badge to reveal quick tips.'
: 'Click the badge to open the contextual menu.',
)
return (
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<p className="text-sm text-text-secondary">{hoverHint}</p>
<div className="flex flex-wrap items-center gap-6">
<CustomPopover
trigger={trigger}
position={position}
manualClose={manualClose}
disabled={disabled}
btnElement={<span className="text-xs font-medium text-text-secondary">Popover trigger</span>}
htmlContent={
<PopoverContent
title={trigger === 'hover' ? 'Quick help' : 'More actions'}
description={trigger === 'hover'
? 'Use hover-triggered popovers for light contextual hints and inline docs.'
: 'Click-triggered popovers are ideal for menus that require user decisions.'}
/>
}
/>
</div>
</div>
)
}
const meta = {
title: 'Base/Feedback/Popover',
component: Template,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Headless UI popover wrapper supporting hover and click triggers. These examples highlight alignment controls and manual closing.',
},
},
},
argTypes: {
trigger: {
control: 'radio',
options: ['hover', 'click'],
},
position: {
control: 'radio',
options: ['bottom', 'bl', 'br'],
},
manualClose: { control: 'boolean' },
disabled: { control: 'boolean' },
},
args: {
trigger: 'hover',
position: 'bottom',
manualClose: false,
disabled: false,
},
tags: ['autodocs'],
} satisfies Meta<typeof Template>
export default meta
type Story = StoryObj<typeof meta>
export const HoverPopover: Story = {}
export const ClickPopover: Story = {
args: {
trigger: 'click',
position: 'br',
},
}
export const DisabledState: Story = {
args: {
disabled: true,
},
}

View File

@@ -0,0 +1,127 @@
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react'
import { Fragment, cloneElement, isValidElement, useRef } from 'react'
import cn from '@/utils/classnames'
export type HtmlContentProps = {
open?: boolean
onClose?: () => void
onClick?: () => void
}
type IPopover = {
className?: string
htmlContent: React.ReactNode
popupClassName?: string
trigger?: 'click' | 'hover'
position?: 'bottom' | 'br' | 'bl'
btnElement?: string | React.ReactNode
btnClassName?: string | ((open: boolean) => string)
manualClose?: boolean
disabled?: boolean
}
const timeoutDuration = 100
export default function CustomPopover({
trigger = 'hover',
position = 'bottom',
htmlContent,
popupClassName,
btnElement,
className,
btnClassName,
manualClose,
disabled = false,
}: IPopover) {
const buttonRef = useRef<HTMLButtonElement>(null)
const timeOutRef = useRef<number | null>(null)
const onMouseEnter = (isOpen: boolean) => {
if (timeOutRef.current != null)
window.clearTimeout(timeOutRef.current)
if (!isOpen)
buttonRef.current?.click()
}
const onMouseLeave = (isOpen: boolean) => {
timeOutRef.current = window.setTimeout(() => {
if (isOpen)
buttonRef.current?.click()
}, timeoutDuration)
}
return (
<Popover className="relative">
{({ open }: { open: boolean }) => {
return (
<>
<div
{...(trigger !== 'hover'
? {}
: {
onMouseLeave: () => onMouseLeave(open),
onMouseEnter: () => onMouseEnter(open),
})}
>
<PopoverButton
ref={buttonRef}
disabled={disabled}
className={cn(
'group inline-flex items-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-base font-medium hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus:outline-none',
open && 'border-components-button-secondary-border bg-components-button-secondary-bg-hover',
(btnClassName && typeof btnClassName === 'string') && btnClassName,
(btnClassName && typeof btnClassName !== 'string') && btnClassName?.(open),
)}
>
{btnElement}
</PopoverButton>
<Transition as={Fragment}>
<PopoverPanel
className={cn(
'absolute z-10 mt-1 w-full max-w-sm px-4 sm:px-0 lg:max-w-3xl',
position === 'bottom' && 'left-1/2 -translate-x-1/2',
position === 'bl' && 'left-0',
position === 'br' && 'right-0',
className,
)}
{...(trigger !== 'hover'
? {}
: {
onMouseLeave: () => onMouseLeave(open),
onMouseEnter: () => onMouseEnter(open),
})
}
>
{({ close }) => (
<div
className={cn('w-fit min-w-[130px] overflow-hidden rounded-lg bg-components-panel-bg shadow-lg ring-1 ring-black/5', popupClassName)}
{...(trigger !== 'hover'
? {}
: {
onMouseLeave: () => onMouseLeave(open),
onMouseEnter: () => onMouseEnter(open),
})
}
>
{isValidElement(htmlContent)
? cloneElement(htmlContent as React.ReactElement<HtmlContentProps>, {
open,
onClose: close,
...(manualClose
? {
onClick: close,
}
: {}),
})
: htmlContent}
</div>
)}
</PopoverPanel>
</Transition>
</div>
</>
)
}}
</Popover>
)
}