File size: 4,149 Bytes
cd99321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'

type Placement = 'top' | 'bottom' | 'left' | 'right'

interface TooltipProps {
  label: string
  placement?: Placement
  delay?: number
  disabled?: boolean
  children: React.ReactElement
}

export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, children }: TooltipProps) {
  const [open, setOpen] = useState(false)
  const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
  const triggerRef = useRef<HTMLElement | null>(null)
  const tooltipRef = useRef<HTMLDivElement | null>(null)
  const timerRef = useRef<number | null>(null)

  const show = () => {
    if (disabled || !label) return
    if (timerRef.current) window.clearTimeout(timerRef.current)
    timerRef.current = window.setTimeout(() => setOpen(true), delay)
  }
  const hide = () => {
    if (timerRef.current) window.clearTimeout(timerRef.current)
    setOpen(false)
  }

  useEffect(() => () => { if (timerRef.current) window.clearTimeout(timerRef.current) }, [])

  useEffect(() => {
    if (!open || !triggerRef.current) return
    const r = triggerRef.current.getBoundingClientRect()
    const tipW = tooltipRef.current?.offsetWidth ?? 0
    const tipH = tooltipRef.current?.offsetHeight ?? 0
    const gap = 6
    let top = 0, left = 0
    if (placement === 'top') { top = r.top - tipH - gap; left = r.left + r.width / 2 - tipW / 2 }
    else if (placement === 'bottom') { top = r.bottom + gap; left = r.left + r.width / 2 - tipW / 2 }
    else if (placement === 'left') { top = r.top + r.height / 2 - tipH / 2; left = r.left - tipW - gap }
    else { top = r.top + r.height / 2 - tipH / 2; left = r.right + gap }
    const pad = 6
    left = Math.max(pad, Math.min(left, window.innerWidth - tipW - pad))
    top = Math.max(pad, Math.min(top, window.innerHeight - tipH - pad))
    setCoords({ top, left })
  }, [open, placement, label])

  // The wrapped child can be any focusable/hoverable element. React's element
  // props are typed as `unknown`, so we narrow to the handlers we chain onto.
  type TriggerProps = {
    ref?: React.Ref<HTMLElement>
    onMouseEnter?: (e: React.MouseEvent) => void
    onMouseLeave?: (e: React.MouseEvent) => void
    onFocus?: (e: React.FocusEvent) => void
    onBlur?: (e: React.FocusEvent) => void
  }
  const child = React.Children.only(children) as React.ReactElement<TriggerProps>
  const childProps = child.props as TriggerProps
  const trigger = React.cloneElement(child, {
    ref: (node: HTMLElement | null) => {
      triggerRef.current = node
      const r = childProps.ref
      if (typeof r === 'function') r(node)
      else if (r && typeof r === 'object') (r as React.RefObject<HTMLElement | null>).current = node
    },
    onMouseEnter: (e: React.MouseEvent) => { show(); childProps.onMouseEnter?.(e) },
    onMouseLeave: (e: React.MouseEvent) => { hide(); childProps.onMouseLeave?.(e) },
    onFocus: (e: React.FocusEvent) => { show(); childProps.onFocus?.(e) },
    onBlur: (e: React.FocusEvent) => { hide(); childProps.onBlur?.(e) },
  } as TriggerProps)

  return (
    <>
      {trigger}
      {open && ReactDOM.createPortal(
        <div
          ref={tooltipRef}
          role="tooltip"
          className="trek-popover-enter bg-surface-card text-content border border-edge-faint"
          style={{
            position: 'fixed',
            top: coords?.top ?? -9999,
            left: coords?.left ?? -9999,
            visibility: coords ? 'visible' : 'hidden',
            pointerEvents: 'none',
            zIndex: 100000,
            fontSize: 11,
            fontWeight: 500,
            padding: '5px 10px',
            borderRadius: 8,
            whiteSpace: 'nowrap',
            boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
            fontFamily: "var(--font-system)",
            transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
          }}
        >
          {label}
        </div>,
        document.body,
      )}
    </>
  )
}

export default Tooltip