index.tsx 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. 'use client'
  2. import React from 'react'
  3. import {
  4. FloatingPortal,
  5. autoUpdate,
  6. flip,
  7. offset,
  8. shift,
  9. size,
  10. useDismiss,
  11. useFloating,
  12. useFocus,
  13. useHover,
  14. useInteractions,
  15. useMergeRefs,
  16. useRole,
  17. } from '@floating-ui/react'
  18. import type { OffsetOptions, Placement } from '@floating-ui/react'
  19. import cn from '@/utils/classnames'
  20. export type PortalToFollowElemOptions = {
  21. /*
  22. * top, bottom, left, right
  23. * start, end. Default is middle
  24. * combine: top-start, top-end
  25. */
  26. placement?: Placement
  27. open?: boolean
  28. offset?: number | OffsetOptions
  29. onOpenChange?: (open: boolean) => void
  30. triggerPopupSameWidth?: boolean
  31. }
  32. export function usePortalToFollowElem({
  33. placement = 'bottom',
  34. open,
  35. offset: offsetValue = 0,
  36. onOpenChange: setControlledOpen,
  37. triggerPopupSameWidth,
  38. }: PortalToFollowElemOptions = {}) {
  39. const setOpen = setControlledOpen
  40. const data = useFloating({
  41. placement,
  42. open,
  43. onOpenChange: setOpen,
  44. whileElementsMounted: autoUpdate,
  45. middleware: [
  46. offset(offsetValue),
  47. flip({
  48. crossAxis: placement.includes('-'),
  49. fallbackAxisSideDirection: 'start',
  50. padding: 5,
  51. }),
  52. shift({ padding: 5 }),
  53. size({
  54. apply({ rects, elements }) {
  55. if (triggerPopupSameWidth)
  56. elements.floating.style.width = `${rects.reference.width}px`
  57. },
  58. }),
  59. ],
  60. })
  61. const context = data.context
  62. const hover = useHover(context, {
  63. move: false,
  64. enabled: open == null,
  65. })
  66. const focus = useFocus(context, {
  67. enabled: open == null,
  68. })
  69. const dismiss = useDismiss(context)
  70. const role = useRole(context, { role: 'tooltip' })
  71. const interactions = useInteractions([hover, focus, dismiss, role])
  72. return React.useMemo(
  73. () => ({
  74. open,
  75. setOpen,
  76. ...interactions,
  77. ...data,
  78. }),
  79. [open, setOpen, interactions, data],
  80. )
  81. }
  82. type ContextType = ReturnType<typeof usePortalToFollowElem> | null
  83. const PortalToFollowElemContext = React.createContext<ContextType>(null)
  84. export function usePortalToFollowElemContext() {
  85. const context = React.useContext(PortalToFollowElemContext)
  86. if (context == null)
  87. throw new Error('PortalToFollowElem components must be wrapped in <PortalToFollowElem />')
  88. return context
  89. }
  90. export function PortalToFollowElem({
  91. children,
  92. ...options
  93. }: { children: React.ReactNode } & PortalToFollowElemOptions) {
  94. // This can accept any props as options, e.g. `placement`,
  95. // or other positioning options.
  96. const tooltip = usePortalToFollowElem(options)
  97. return (
  98. <PortalToFollowElemContext.Provider value={tooltip}>
  99. {children}
  100. </PortalToFollowElemContext.Provider>
  101. )
  102. }
  103. export const PortalToFollowElemTrigger = (
  104. {
  105. ref: propRef,
  106. children,
  107. asChild = false,
  108. ...props
  109. }: React.HTMLProps<HTMLElement> & { ref?: React.RefObject<HTMLElement>, asChild?: boolean },
  110. ) => {
  111. const context = usePortalToFollowElemContext()
  112. const childrenRef = (children as any).props?.ref
  113. const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
  114. // `asChild` allows the user to pass any element as the anchor
  115. if (asChild && React.isValidElement(children)) {
  116. return React.cloneElement(
  117. children,
  118. context.getReferenceProps({
  119. ref,
  120. ...props,
  121. ...children.props,
  122. 'data-state': context.open ? 'open' : 'closed',
  123. }),
  124. )
  125. }
  126. return (
  127. <div
  128. ref={ref}
  129. className={cn('inline-block', props.className)}
  130. // The user can style the trigger based on the state
  131. data-state={context.open ? 'open' : 'closed'}
  132. {...context.getReferenceProps(props)}
  133. >
  134. {children}
  135. </div>
  136. )
  137. }
  138. PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
  139. export const PortalToFollowElemContent = (
  140. {
  141. ref: propRef,
  142. style,
  143. ...props
  144. }: React.HTMLProps<HTMLDivElement> & {
  145. ref?: React.RefObject<HTMLDivElement>;
  146. },
  147. ) => {
  148. const context = usePortalToFollowElemContext()
  149. const ref = useMergeRefs([context.refs.setFloating, propRef])
  150. if (!context.open)
  151. return null
  152. const body = document.body
  153. return (
  154. <FloatingPortal root={body}>
  155. <div
  156. ref={ref}
  157. style={{
  158. ...context.floatingStyles,
  159. ...style,
  160. }}
  161. {...context.getFloatingProps(props)}
  162. />
  163. </FloatingPortal>
  164. )
  165. }
  166. PortalToFollowElemContent.displayName = 'PortalToFollowElemContent'