index.tsx 3.9 KB

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