288 lines
7.2 KiB
TypeScript
288 lines
7.2 KiB
TypeScript
"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 }
|