| 1 | import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; |
| 2 | import { createContext, use, useEffect, useRef, type ComponentProps, type RefObject } from "react"; |
| 3 | |
| 4 | import { cn } from "~/lib/utils"; |
| 5 | import { useNativeTitlebarHeight } from "~/lib/windowControlsOverlay"; |
| 6 | |
| 7 | // Base UI's default, restated because a padding object zeroes the sides it omits. |
| 8 | const COLLISION_PADDING = 5; |
| 9 | |
| 10 | const TooltipProvider = TooltipPrimitive.Provider; |
| 11 | |
| 12 | type TooltipActionsRef = RefObject<TooltipPrimitive.Root.Actions | null>; |
| 13 | const TooltipHoverContext = createContext<TooltipActionsRef | null>(null); |
| 14 | const TooltipScrollContext = createContext<RefObject<{ |
| 15 | trigger: HTMLElement; |
| 16 | actionsRef: TooltipActionsRef; |
| 17 | } | null> | null>(null); |
| 18 | |
| 19 | /** Dismisses hovered descendants on real scroll events without rerendering the timeline. */ |
| 20 | function TooltipScrollDismissArea({ onScrollCapture, ...props }: ComponentProps<"div">) { |
| 21 | const hovered = useRef<{ trigger: HTMLElement; actionsRef: TooltipActionsRef } | null>(null); |
| 22 | return ( |
| 23 | <TooltipScrollContext value={hovered}> |
| 24 | <div |
| 25 | {...props} |
| 26 | onScrollCapture={(event) => { |
| 27 | onScrollCapture?.(event); |
| 28 | const tooltip = hovered.current; |
| 29 | if (!tooltip || tooltip.trigger.contains(tooltip.trigger.ownerDocument.activeElement)) { |
| 30 | return; |
| 31 | } |
| 32 | hovered.current = null; |
| 33 | // Base UI also cancels delayed hover opens through this action. |
| 34 | tooltip.actionsRef.current?.close(); |
| 35 | }} |
| 36 | /> |
| 37 | </TooltipScrollContext> |
| 38 | ); |
| 39 | } |
| 40 | |
| 41 | function Tooltip<Payload>(props: TooltipPrimitive.Root.Props<Payload>) { |
| 42 | const hovered = use(TooltipScrollContext); |
| 43 | const localActionsRef = useRef<TooltipPrimitive.Root.Actions | null>(null); |
| 44 | const actionsRef = props.actionsRef ?? localActionsRef; |
| 45 | useEffect( |
| 46 | () => () => { |
| 47 | if (hovered?.current?.actionsRef === actionsRef) hovered.current = null; |
| 48 | }, |
| 49 | [actionsRef, hovered], |
| 50 | ); |
| 51 | |
| 52 | if (!hovered) return <TooltipPrimitive.Root {...props} />; |
| 53 | return ( |
| 54 | <TooltipHoverContext value={actionsRef}> |
| 55 | <TooltipPrimitive.Root |
| 56 | {...props} |
| 57 | actionsRef={actionsRef} |
| 58 | onOpenChange={(open, details) => { |
| 59 | props.onOpenChange?.(open, details); |
| 60 | if (!open && !details.isCanceled && hovered.current?.actionsRef === actionsRef) { |
| 61 | hovered.current = null; |
| 62 | } |
| 63 | }} |
| 64 | /> |
| 65 | </TooltipHoverContext> |
| 66 | ); |
| 67 | } |
| 68 | |
| 69 | function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) { |
| 70 | const hovered = use(TooltipScrollContext); |
| 71 | const actionsRef = use(TooltipHoverContext); |
| 72 | if (!hovered || !actionsRef) { |
| 73 | return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />; |
| 74 | } |
| 75 | return ( |
| 76 | <TooltipPrimitive.Trigger |
| 77 | data-slot="tooltip-trigger" |
| 78 | {...props} |
| 79 | onMouseEnter={(event) => { |
| 80 | props.onMouseEnter?.(event); |
| 81 | hovered.current = { trigger: event.currentTarget, actionsRef }; |
| 82 | }} |
| 83 | /> |
| 84 | ); |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Treats the native titlebar strip as off-screen, so a tooltip that would open |
| 89 | * under the window controls flips to its other side. Rendered inside the |
| 90 | * portal so only open tooltips track the titlebar. |
| 91 | */ |
| 92 | function TooltipPositioner(props: TooltipPrimitive.Positioner.Props) { |
| 93 | const titlebarHeight = useNativeTitlebarHeight(); |
| 94 | return ( |
| 95 | <TooltipPrimitive.Positioner |
| 96 | className="pointer-events-none z-[140] h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] data-instant:transition-none" |
| 97 | collisionPadding={{ |
| 98 | top: COLLISION_PADDING + titlebarHeight, |
| 99 | right: COLLISION_PADDING, |
| 100 | bottom: COLLISION_PADDING, |
| 101 | left: COLLISION_PADDING, |
| 102 | }} |
| 103 | data-slot="tooltip-positioner" |
| 104 | {...props} |
| 105 | /> |
| 106 | ); |
| 107 | } |
| 108 | |
| 109 | function TooltipPopup({ |
| 110 | className, |
| 111 | align = "center", |
| 112 | sideOffset = 4, |
| 113 | side = "top", |
| 114 | variant = "default", |
| 115 | anchor, |
| 116 | children, |
| 117 | ...props |
| 118 | }: TooltipPrimitive.Popup.Props & { |
| 119 | align?: TooltipPrimitive.Positioner.Props["align"]; |
| 120 | side?: TooltipPrimitive.Positioner.Props["side"]; |
| 121 | sideOffset?: TooltipPrimitive.Positioner.Props["sideOffset"]; |
| 122 | /** `code` renders monospace content that breaks anywhere, for paths and commands. */ |
| 123 | variant?: "default" | "glass" | "code"; |
| 124 | anchor?: TooltipPrimitive.Positioner.Props["anchor"]; |
| 125 | }) { |
| 126 | return ( |
| 127 | <TooltipPrimitive.Portal> |
| 128 | <TooltipPositioner align={align} anchor={anchor} side={side} sideOffset={sideOffset}> |
| 129 | <TooltipPrimitive.Popup |
| 130 | className={cn( |
| 131 | "relative flex h-(--popup-height,auto) w-(--popup-width,auto) origin-(--transform-origin) text-balance rounded-md text-popover-foreground text-xs transition-[width,height,scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:duration-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", |
| 132 | variant === "glass" |
| 133 | ? "dropdown-glass shadow-xl shadow-black/25 before:hidden" |
| 134 | : "border bg-popover not-dark:bg-clip-padding shadow-md/5", |
| 135 | // One wrap width for prose; code dumps get more room and break anywhere. |
| 136 | variant === "code" |
| 137 | ? "max-w-120 wrap-anywhere text-left font-mono text-[11px] leading-relaxed" |
| 138 | : "max-w-80 wrap-anywhere whitespace-normal leading-snug", |
| 139 | className, |
| 140 | )} |
| 141 | data-slot="tooltip-popup" |
| 142 | {...props} |
| 143 | > |
| 144 | <TooltipPrimitive.Viewport |
| 145 | className="relative size-full overflow-clip px-(--viewport-inline-padding) py-1 [--viewport-inline-padding:--spacing(2)] data-instant:transition-none **:data-current:data-ending-style:opacity-0 **:data-current:data-starting-style:opacity-0 **:data-previous:data-ending-style:opacity-0 **:data-previous:data-starting-style:opacity-0 **:data-current:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:truncate **:data-current:opacity-100 **:data-previous:opacity-100 **:data-current:transition-opacity **:data-previous:transition-opacity" |
| 146 | data-slot="tooltip-viewport" |
| 147 | > |
| 148 | {children} |
| 149 | </TooltipPrimitive.Viewport> |
| 150 | </TooltipPrimitive.Popup> |
| 151 | </TooltipPositioner> |
| 152 | </TooltipPrimitive.Portal> |
| 153 | ); |
| 154 | } |
| 155 | |
| 156 | export { TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup, TooltipScrollDismissArea }; |