|
'use client' |
|
import React from 'react' |
|
import { |
|
FloatingPortal, |
|
autoUpdate, |
|
flip, |
|
offset, |
|
shift, |
|
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 = { |
|
|
|
|
|
|
|
|
|
|
|
placement?: Placement |
|
open?: boolean |
|
offset?: number | OffsetOptions |
|
onOpenChange?: (open: boolean) => void |
|
} |
|
|
|
export function usePortalToFollowElem({ |
|
placement = 'bottom', |
|
open, |
|
offset: offsetValue = 0, |
|
onOpenChange: setControlledOpen, |
|
}: PortalToFollowElemOptions = {}) { |
|
const setOpen = setControlledOpen |
|
|
|
const data = useFloating({ |
|
placement, |
|
open, |
|
onOpenChange: setOpen, |
|
whileElementsMounted: autoUpdate, |
|
middleware: [ |
|
offset(offsetValue), |
|
flip({ |
|
crossAxis: placement.includes('-'), |
|
fallbackAxisSideDirection: 'start', |
|
padding: 5, |
|
}), |
|
shift({ padding: 5 }), |
|
], |
|
}) |
|
|
|
const context = data.context |
|
|
|
const hover = useHover(context, { |
|
move: false, |
|
enabled: open == null, |
|
}) |
|
const focus = useFocus(context, { |
|
enabled: open == null, |
|
}) |
|
const dismiss = useDismiss(context) |
|
const role = useRole(context, { role: 'tooltip' }) |
|
|
|
const interactions = useInteractions([hover, focus, dismiss, role]) |
|
|
|
return React.useMemo( |
|
() => ({ |
|
open, |
|
setOpen, |
|
...interactions, |
|
...data, |
|
}), |
|
[open, setOpen, 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) { |
|
|
|
|
|
const tooltip = usePortalToFollowElem(options) |
|
return ( |
|
<PortalToFollowElemContext.Provider value={tooltip}> |
|
{children} |
|
</PortalToFollowElemContext.Provider> |
|
) |
|
} |
|
|
|
export const PortalToFollowElemTrigger = React.forwardRef< |
|
HTMLElement, |
|
React.HTMLProps<HTMLElement> & { asChild?: boolean } |
|
>(({ children, asChild = false, ...props }, propRef) => { |
|
const context = usePortalToFollowElemContext() |
|
const childrenRef = (children as any).ref |
|
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]) |
|
|
|
|
|
if (asChild && React.isValidElement(children)) { |
|
return React.cloneElement( |
|
children, |
|
context.getReferenceProps({ |
|
ref, |
|
...props, |
|
...children.props, |
|
'data-state': context.open ? 'open' : 'closed', |
|
}), |
|
) |
|
} |
|
|
|
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 = React.forwardRef< |
|
HTMLDivElement, |
|
React.HTMLProps<HTMLDivElement> |
|
>(({ style, ...props }, propRef) => { |
|
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, |
|
}} |
|
{...context.getFloatingProps(props)} |
|
/> |
|
</FloatingPortal> |
|
) |
|
}) |
|
|
|
PortalToFollowElemContent.displayName = 'PortalToFollowElemContent' |
|
|