您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

index.tsx 4.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. 'use client'
  2. import React, { useCallback, useState } 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: controlledOpen,
  35. offset: offsetValue = 0,
  36. onOpenChange: setControlledOpen,
  37. triggerPopupSameWidth,
  38. }: PortalToFollowElemOptions = {}) {
  39. const [localOpen, setLocalOpen] = useState(false)
  40. const open = controlledOpen ?? localOpen
  41. const handleOpenChange = useCallback((newOpen: boolean) => {
  42. setLocalOpen(newOpen)
  43. setControlledOpen?.(newOpen)
  44. }, [setControlledOpen, setLocalOpen])
  45. const data = useFloating({
  46. placement,
  47. open,
  48. onOpenChange: handleOpenChange,
  49. whileElementsMounted: autoUpdate,
  50. middleware: [
  51. offset(offsetValue),
  52. flip({
  53. crossAxis: placement.includes('-'),
  54. fallbackAxisSideDirection: 'start',
  55. padding: 5,
  56. }),
  57. shift({ padding: 5 }),
  58. size({
  59. apply({ rects, elements }) {
  60. if (triggerPopupSameWidth)
  61. elements.floating.style.width = `${rects.reference.width}px`
  62. },
  63. }),
  64. ],
  65. })
  66. const context = data.context
  67. const hover = useHover(context, {
  68. move: false,
  69. enabled: controlledOpen === undefined,
  70. })
  71. const focus = useFocus(context, {
  72. enabled: controlledOpen === undefined,
  73. })
  74. const dismiss = useDismiss(context)
  75. const role = useRole(context, { role: 'tooltip' })
  76. const interactions = useInteractions([hover, focus, dismiss, role])
  77. return React.useMemo(
  78. () => ({
  79. open,
  80. setOpen: handleOpenChange,
  81. ...interactions,
  82. ...data,
  83. }),
  84. [open, handleOpenChange, interactions, data],
  85. )
  86. }
  87. type ContextType = ReturnType<typeof usePortalToFollowElem> | null
  88. const PortalToFollowElemContext = React.createContext<ContextType>(null)
  89. export function usePortalToFollowElemContext() {
  90. const context = React.useContext(PortalToFollowElemContext)
  91. if (context == null)
  92. throw new Error('PortalToFollowElem components must be wrapped in <PortalToFollowElem />')
  93. return context
  94. }
  95. export function PortalToFollowElem({
  96. children,
  97. ...options
  98. }: { children: React.ReactNode } & PortalToFollowElemOptions) {
  99. // This can accept any props as options, e.g. `placement`,
  100. // or other positioning options.
  101. const tooltip = usePortalToFollowElem(options)
  102. return (
  103. <PortalToFollowElemContext.Provider value={tooltip}>
  104. {children}
  105. </PortalToFollowElemContext.Provider>
  106. )
  107. }
  108. export const PortalToFollowElemTrigger = (
  109. {
  110. ref: propRef,
  111. children,
  112. asChild = false,
  113. ...props
  114. }: React.HTMLProps<HTMLElement> & { ref?: React.RefObject<HTMLElement>, asChild?: boolean },
  115. ) => {
  116. const context = usePortalToFollowElemContext()
  117. const childrenRef = (children as any).props?.ref
  118. const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
  119. // `asChild` allows the user to pass any element as the anchor
  120. if (asChild && React.isValidElement(children)) {
  121. return React.cloneElement(
  122. children,
  123. context.getReferenceProps({
  124. ref,
  125. ...props,
  126. ...children.props,
  127. 'data-state': context.open ? 'open' : 'closed',
  128. } as React.HTMLProps<HTMLElement>),
  129. )
  130. }
  131. return (
  132. <div
  133. ref={ref}
  134. className={cn('inline-block', props.className)}
  135. // The user can style the trigger based on the state
  136. data-state={context.open ? 'open' : 'closed'}
  137. {...context.getReferenceProps(props)}
  138. >
  139. {children}
  140. </div>
  141. )
  142. }
  143. PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
  144. export const PortalToFollowElemContent = (
  145. {
  146. ref: propRef,
  147. style,
  148. ...props
  149. }: React.HTMLProps<HTMLDivElement> & {
  150. ref?: React.RefObject<HTMLDivElement>;
  151. },
  152. ) => {
  153. const context = usePortalToFollowElemContext()
  154. const ref = useMergeRefs([context.refs.setFloating, propRef])
  155. if (!context.open)
  156. return null
  157. const body = document.body
  158. return (
  159. <FloatingPortal root={body}>
  160. <div
  161. ref={ref}
  162. style={{
  163. ...context.floatingStyles,
  164. ...style,
  165. visibility: context.middlewareData.hide?.referenceHidden ? 'hidden' : 'visible',
  166. }}
  167. {...context.getFloatingProps(props)}
  168. />
  169. </FloatingPortal>
  170. )
  171. }
  172. PortalToFollowElemContent.displayName = 'PortalToFollowElemContent'