组件
形变弹出框 Morphing Popover
从触发器原位展开成轻量交互面板的弹出层。
基础示例
Loading…
pnpm dlx wui@latest add @wui/morphing-popover"use client"
import * as React from "react"
import { Slot } from "radix-ui"
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
type Transition,
type Variants,
} from "motion/react"
import { cn } from "@/lib/utils"
const defaultTransition = {
type: "spring",
stiffness: 460,
damping: 36,
mass: 0.65,
} as const
type MorphingPopoverContextValue = {
open: boolean
setOpen: (open: boolean) => void
layoutId: string
transition: Transition
variants?: Variants
triggerRef: React.RefObject<HTMLElement | null>
contentRef: React.RefObject<HTMLDivElement | null>
}
const MorphingPopoverContext =
React.createContext<MorphingPopoverContextValue | null>(null)
function useMorphingPopover() {
const context = React.useContext(MorphingPopoverContext)
if (!context) {
throw new Error(
"MorphingPopover parts must be used inside <MorphingPopover>."
)
}
return context
}
export interface MorphingPopoverProps extends React.ComponentProps<"div"> {
/** Whether the popover is open in controlled mode. */
open?: boolean
/** Initial state in uncontrolled mode. @default false */
defaultOpen?: boolean
/** Called whenever the open state changes. */
onOpenChange?: (open: boolean) => void
/** Spring or tween used by the shared-layout morph. */
transition?: Transition
/** Motion variants for content entering and leaving the expanded surface. */
variants?: Variants
}
function MorphingPopover({
open: openProp,
defaultOpen = false,
onOpenChange,
transition = defaultTransition,
variants,
className,
children,
...props
}: MorphingPopoverProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
const open = openProp ?? uncontrolledOpen
const layoutId = React.useId()
const triggerRef = React.useRef<HTMLElement | null>(null)
const contentRef = React.useRef<HTMLDivElement | null>(null)
const wasOpen = React.useRef(open)
const setOpen = React.useCallback(
(nextOpen: boolean) => {
if (openProp === undefined) setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
},
[onOpenChange, openProp]
)
React.useEffect(() => {
if (!open) return
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (
!contentRef.current?.contains(target) &&
!triggerRef.current?.contains(target)
) {
setOpen(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false)
}
document.addEventListener("pointerdown", handlePointerDown)
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("pointerdown", handlePointerDown)
document.removeEventListener("keydown", handleKeyDown)
}
}, [open, setOpen])
React.useEffect(() => {
if (open) {
contentRef.current
?.querySelector<HTMLElement>(
"[autofocus], textarea, input, select, button, [href], [tabindex]:not([tabindex='-1'])"
)
?.focus()
} else if (wasOpen.current) {
triggerRef.current?.focus()
}
wasOpen.current = open
}, [open])
return (
<MorphingPopoverContext.Provider
value={{
open,
setOpen,
layoutId,
transition,
variants,
triggerRef,
contentRef,
}}
>
<LayoutGroup id={layoutId}>
<div
data-slot="morphing-popover"
className={cn("relative inline-flex", className)}
{...props}
>
{children}
</div>
</LayoutGroup>
</MorphingPopoverContext.Provider>
)
}
export interface MorphingPopoverTriggerProps extends React.ComponentProps<"button"> {
/** Merge trigger behavior onto the single child element. @default false */
asChild?: boolean
}
function MorphingPopoverTrigger({
className,
children,
asChild = false,
onClick,
...props
}: MorphingPopoverTriggerProps) {
const { open, setOpen, layoutId, transition, triggerRef } =
useMorphingPopover()
const reduceMotion = useReducedMotion()
const Comp = asChild ? Slot.Root : "button"
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event)
if (!event.defaultPrevented) setOpen(true)
}
return (
<span
className="relative inline-flex"
data-slot="morphing-popover-trigger-wrapper"
>
{!open ? (
<motion.span
aria-hidden
layoutId={`${layoutId}-surface`}
className="bg-background shadow-xs pointer-events-none absolute inset-0 rounded-md border"
transition={reduceMotion ? { duration: 0 } : transition}
/>
) : null}
<Comp
ref={triggerRef as React.Ref<HTMLButtonElement>}
type={asChild ? undefined : "button"}
data-slot="morphing-popover-trigger"
aria-expanded={open}
aria-haspopup="dialog"
className={cn(
"focus-visible:ring-ring/50 relative z-10 inline-flex min-h-9 items-center justify-center rounded-md px-4 text-sm font-medium outline-none transition-opacity focus-visible:ring-[3px]",
open && "pointer-events-none opacity-0",
className
)}
onClick={handleClick}
{...props}
>
{children}
</Comp>
</span>
)
}
export interface MorphingPopoverContentProps extends React.ComponentProps<
typeof motion.div
> {}
function MorphingPopoverContent({
className,
children,
...props
}: MorphingPopoverContentProps) {
const { open, layoutId, transition, variants, contentRef } =
useMorphingPopover()
const reduceMotion = useReducedMotion()
const contentVariants: Variants = variants ?? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
}
return (
<AnimatePresence initial={false}>
{open ? (
<motion.div
data-slot="morphing-popover-positioner"
className="absolute left-1/2 top-1/2 z-50 w-max -translate-x-1/2 -translate-y-1/2"
>
<motion.div
ref={contentRef}
data-slot="morphing-popover-content"
role="dialog"
layoutId={`${layoutId}-surface`}
className={cn(
"bg-popover text-popover-foreground relative w-72 overflow-hidden rounded-lg border p-4 shadow-md outline-none",
className
)}
variants={reduceMotion ? undefined : contentVariants}
initial={reduceMotion ? false : "initial"}
animate="animate"
exit="exit"
transition={reduceMotion ? { duration: 0 } : transition}
{...props}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
)
}
export interface MorphingPopoverCloseProps extends React.ComponentProps<"button"> {
/** Merge close behavior onto the single child element. @default false */
asChild?: boolean
}
function MorphingPopoverClose({
asChild = false,
onClick,
children,
...props
}: MorphingPopoverCloseProps) {
const { setOpen } = useMorphingPopover()
const Comp = asChild ? Slot.Root : "button"
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event)
if (!event.defaultPrevented) setOpen(false)
}
return (
<Comp
type={asChild ? undefined : "button"}
data-slot="morphing-popover-close"
onClick={handleClick}
{...props}
>
{children}
</Comp>
)
}
export {
MorphingPopover,
MorphingPopoverClose,
MorphingPopoverContent,
MorphingPopoverTrigger,
}
组件作用
MorphingPopover 让触发器的背景表面自然扩展为锚定面板,适合快速编辑、筛选器、简短表单和上下文操作。它不会像 Dialog 一样阻断页面其余区域,因此不适合必须完成或明确取消的关键流程。
面板与触发器处在同一个相对定位容器内,因此 shared layout 始终使用同一套坐标,不会受到 Portal 浮动定位的临时 transform 干扰。组件支持点外关闭、Esc 和焦点归还。
组件属性
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Whether the popover is open in controlled mode. |
| defaultOpen | boolean | false | Initial state in uncontrolled mode. |
| onOpenChange | ((open: boolean) => void) | — | Called whenever the open state changes. |
| transition | Transition | { type: "spring", stiffness: 460, damping: 36, mass: 0.65, } as const | Spring or tween used by the shared-layout morph. |
| variants | Variants | — | Motion variants for content entering and leaving the expanded surface. |
MorphingPopoverContent 接受 Motion div 的属性,className 用于设置展开后面板的尺寸和外观。面板默认以触发器中心为形变原点。
事件
onOpenChange(open) 在打开状态变化时调用。受控模式使用 open,非受控模式使用 defaultOpen。
扩展使用
通过根组件的 transition 调整形变手感,通过 variants 自定义内部内容的出现方式。对于尺寸差异特别大的触发器与面板,应保持弹簧阻尼较高,避免边缘过度回弹。