casualroom/apps/fenghuo/web/components/tiptap-ui-primitive/popover/popover.tsx

288 lines
7.2 KiB
TypeScript
Executable File

"use client"
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from "react"
import type { Placement } from "@floating-ui/react"
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useClick,
useDismiss,
useRole,
useInteractions,
useMergeRefs,
FloatingFocusManager,
limitShift,
FloatingPortal,
} from "@floating-ui/react"
import "@/components/tiptap-ui-primitive/popover/popover.scss"
type PopoverContextValue = ReturnType<typeof usePopover> & {
setLabelId: (id: string | undefined) => void
setDescriptionId: (id: string | undefined) => void
updatePosition: (
side: "top" | "right" | "bottom" | "left",
align: "start" | "center" | "end",
sideOffset?: number,
alignOffset?: number
) => void
}
interface PopoverOptions {
initialOpen?: boolean
modal?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
side?: "top" | "right" | "bottom" | "left"
align?: "start" | "center" | "end"
sideOffset?: number
alignOffset?: number
}
interface PopoverProps extends PopoverOptions {
children: React.ReactNode
}
const PopoverContext = React.createContext<PopoverContextValue | null>(null)
function usePopoverContext() {
const context = React.useContext(PopoverContext)
if (!context) {
throw new Error("Popover components must be wrapped in <Popover />")
}
return context
}
function usePopover({
initialOpen = false,
modal,
open: controlledOpen,
onOpenChange: setControlledOpen,
side = "bottom",
align = "center",
sideOffset = 4,
alignOffset = 0,
}: PopoverOptions = {}) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen)
const [labelId, setLabelId] = React.useState<string>()
const [descriptionId, setDescriptionId] = React.useState<string>()
const [currentPlacement, setCurrentPlacement] = React.useState<Placement>(
`${side}-${align}` as Placement
)
const [offsets, setOffsets] = React.useState({ sideOffset, alignOffset })
const open = controlledOpen ?? uncontrolledOpen
const setOpen = setControlledOpen ?? setUncontrolledOpen
const middleware = React.useMemo(
() => [
offset({
mainAxis: offsets.sideOffset,
crossAxis: offsets.alignOffset,
}),
flip({
fallbackAxisSideDirection: "end",
crossAxis: false,
}),
shift({
limiter: limitShift({ offset: offsets.sideOffset }),
}),
],
[offsets.sideOffset, offsets.alignOffset]
)
const floating = useFloating({
placement: currentPlacement,
open,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware,
})
const interactions = useInteractions([
useClick(floating.context),
useDismiss(floating.context),
useRole(floating.context),
])
const updatePosition = React.useCallback(
(
newSide: "top" | "right" | "bottom" | "left",
newAlign: "start" | "center" | "end",
newSideOffset?: number,
newAlignOffset?: number
) => {
setCurrentPlacement(`${newSide}-${newAlign}` as Placement)
if (newSideOffset !== undefined || newAlignOffset !== undefined) {
setOffsets({
sideOffset: newSideOffset ?? offsets.sideOffset,
alignOffset: newAlignOffset ?? offsets.alignOffset,
})
}
},
[offsets.sideOffset, offsets.alignOffset]
)
return React.useMemo(
() => ({
open,
setOpen,
...interactions,
...floating,
modal,
labelId,
descriptionId,
setLabelId,
setDescriptionId,
updatePosition,
}),
[
open,
setOpen,
interactions,
floating,
modal,
labelId,
descriptionId,
updatePosition,
]
)
}
function Popover({ children, modal = false, ...options }: PopoverProps) {
const popover = usePopover({ modal, ...options })
return (
<PopoverContext.Provider value={popover}>
{children}
</PopoverContext.Provider>
)
}
interface TriggerElementProps extends React.HTMLProps<HTMLElement> {
asChild?: boolean
}
const PopoverTrigger = React.forwardRef<HTMLElement, TriggerElementProps>(
function PopoverTrigger({ children, asChild = false, ...props }, propRef) {
const context = usePopoverContext()
const childrenRef = React.isValidElement(children)
? parseInt(React.version, 10) >= 19
? (children.props as any).ref
: (children as any).ref
: undefined
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
if (asChild && React.isValidElement(children)) {
return React.cloneElement(
children,
context.getReferenceProps({
ref,
...props,
...(children.props as any),
"data-state": context.open ? "open" : "closed",
})
)
}
return (
<button
ref={ref}
data-state={context.open ? "open" : "closed"}
{...context.getReferenceProps(props)}
>
{children}
</button>
)
}
)
interface PopoverContentProps extends React.HTMLProps<HTMLDivElement> {
side?: "top" | "right" | "bottom" | "left"
align?: "start" | "center" | "end"
sideOffset?: number
alignOffset?: number
portal?: boolean
portalProps?: Omit<React.ComponentProps<typeof FloatingPortal>, "children">
asChild?: boolean
}
const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
function PopoverContent(
{
className,
side = "bottom",
align = "center",
sideOffset,
alignOffset,
style,
portal = true,
portalProps = {},
asChild = false,
children,
...props
},
propRef
) {
const context = usePopoverContext()
const childrenRef = React.isValidElement(children)
? parseInt(React.version, 10) >= 19
? (children.props as any).ref
: (children as any).ref
: undefined
const ref = useMergeRefs([context.refs.setFloating, propRef, childrenRef])
React.useEffect(() => {
context.updatePosition(side, align, sideOffset, alignOffset)
}, [context, side, align, sideOffset, alignOffset])
if (!context.context.open) return null
const contentProps = {
ref,
style: {
position: context.strategy,
top: context.y ?? 0,
left: context.x ?? 0,
...style,
},
"aria-labelledby": context.labelId,
"aria-describedby": context.descriptionId,
className: `tiptap-popover ${className || ""}`,
"data-side": side,
"data-align": align,
"data-state": context.context.open ? "open" : "closed",
...context.getFloatingProps(props),
}
const content =
asChild && React.isValidElement(children) ? (
React.cloneElement(children, {
...contentProps,
...(children.props as any),
})
) : (
<div {...contentProps}>{children}</div>
)
const wrappedContent = (
<FloatingFocusManager context={context.context} modal={context.modal}>
{content}
</FloatingFocusManager>
)
if (portal) {
return <FloatingPortal {...portalProps}>{wrappedContent}</FloatingPortal>
}
return wrappedContent
}
)
PopoverTrigger.displayName = "PopoverTrigger"
PopoverContent.displayName = "PopoverContent"
export { Popover, PopoverTrigger, PopoverContent }