index.tsx 4.1 KB

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