| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- 'use client'
- import React, { useCallback, useState } from 'react'
- import {
- FloatingPortal,
- autoUpdate,
- flip,
- offset,
- shift,
- size,
- 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 = {
- /*
- * top, bottom, left, right
- * start, end. Default is middle
- * combine: top-start, top-end
- */
- placement?: Placement
- open?: boolean
- offset?: number | OffsetOptions
- onOpenChange?: (open: boolean) => void
- triggerPopupSameWidth?: boolean
- }
-
- export function usePortalToFollowElem({
- placement = 'bottom',
- open: controlledOpen,
- offset: offsetValue = 0,
- onOpenChange: setControlledOpen,
- triggerPopupSameWidth,
- }: PortalToFollowElemOptions = {}) {
- const [localOpen, setLocalOpen] = useState(false)
- const open = controlledOpen ?? localOpen
- const handleOpenChange = useCallback((newOpen: boolean) => {
- setLocalOpen(newOpen)
- setControlledOpen?.(newOpen)
- }, [setControlledOpen, setLocalOpen])
-
- const data = useFloating({
- placement,
- open,
- onOpenChange: handleOpenChange,
- whileElementsMounted: autoUpdate,
- middleware: [
- offset(offsetValue),
- flip({
- crossAxis: placement.includes('-'),
- fallbackAxisSideDirection: 'start',
- padding: 5,
- }),
- shift({ padding: 5 }),
- size({
- apply({ rects, elements }) {
- if (triggerPopupSameWidth)
- elements.floating.style.width = `${rects.reference.width}px`
- },
- }),
- ],
- })
-
- const context = data.context
-
- const hover = useHover(context, {
- move: false,
- enabled: controlledOpen === undefined,
- })
- const focus = useFocus(context, {
- enabled: controlledOpen === undefined,
- })
- const dismiss = useDismiss(context)
- const role = useRole(context, { role: 'tooltip' })
-
- const interactions = useInteractions([hover, focus, dismiss, role])
-
- return React.useMemo(
- () => ({
- open,
- setOpen: handleOpenChange,
- ...interactions,
- ...data,
- }),
- [open, handleOpenChange, 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) {
- // This can accept any props as options, e.g. `placement`,
- // or other positioning options.
- const tooltip = usePortalToFollowElem(options)
- return (
- <PortalToFollowElemContext.Provider value={tooltip}>
- {children}
- </PortalToFollowElemContext.Provider>
- )
- }
-
- export const PortalToFollowElemTrigger = (
- {
- ref: propRef,
- children,
- asChild = false,
- ...props
- }: React.HTMLProps<HTMLElement> & { ref?: React.RefObject<HTMLElement>, asChild?: boolean },
- ) => {
- const context = usePortalToFollowElemContext()
- const childrenRef = (children as any).props?.ref
- const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
-
- // `asChild` allows the user to pass any element as the anchor
- if (asChild && React.isValidElement(children)) {
- return React.cloneElement(
- children,
- context.getReferenceProps({
- ref,
- ...props,
- ...children.props,
- 'data-state': context.open ? 'open' : 'closed',
- } as React.HTMLProps<HTMLElement>),
- )
- }
-
- 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 = (
- {
- ref: propRef,
- style,
- ...props
- }: React.HTMLProps<HTMLDivElement> & {
- ref?: React.RefObject<HTMLDivElement>;
- },
- ) => {
- 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,
- visibility: context.middlewareData.hide?.referenceHidden ? 'hidden' : 'visible',
- }}
- {...context.getFloatingProps(props)}
- />
- </FloatingPortal>
- )
- }
-
- PortalToFollowElemContent.displayName = 'PortalToFollowElemContent'
|