| b69ab31 | | | 1 | /** |
| b69ab31 | | | 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| b69ab31 | | | 3 | * |
| b69ab31 | | | 4 | * This source code is licensed under the MIT license found in the |
| b69ab31 | | | 5 | * LICENSE file in the root directory of this source tree. |
| b69ab31 | | | 6 | */ |
| b69ab31 | | | 7 | |
| b69ab31 | | | 8 | import type {MouseEvent, ReactNode} from 'react'; |
| b69ab31 | | | 9 | import type {ExclusiveOr} from './Types'; |
| b69ab31 | | | 10 | |
| b69ab31 | | | 11 | import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; |
| b69ab31 | | | 12 | import {ViewportOverlay} from './ViewportOverlay'; |
| b69ab31 | | | 13 | import {findParentWithClassName} from './utils'; |
| b69ab31 | | | 14 | import {getZoomLevel} from './zoom'; |
| b69ab31 | | | 15 | |
| b69ab31 | | | 16 | import './Tooltip.css'; |
| b69ab31 | | | 17 | |
| b69ab31 | | | 18 | export type Placement = 'top' | 'bottom' | 'left' | 'right'; |
| b69ab31 | | | 19 | |
| b69ab31 | | | 20 | /** |
| b69ab31 | | | 21 | * Default delay used for hover tooltips to convey documentation information. |
| b69ab31 | | | 22 | */ |
| b69ab31 | | | 23 | export const DOCUMENTATION_DELAY = 750; |
| b69ab31 | | | 24 | |
| b69ab31 | | | 25 | const tooltipGroups: Map<string, EventTarget> = new Map(); |
| b69ab31 | | | 26 | function getTooltipGroup(group: string): EventTarget { |
| b69ab31 | | | 27 | let found = tooltipGroups.get(group); |
| b69ab31 | | | 28 | if (found == null) { |
| b69ab31 | | | 29 | found = new EventTarget(); |
| b69ab31 | | | 30 | tooltipGroups.set(group, found); |
| b69ab31 | | | 31 | } |
| b69ab31 | | | 32 | return found; |
| b69ab31 | | | 33 | } |
| b69ab31 | | | 34 | |
| b69ab31 | | | 35 | /** |
| b69ab31 | | | 36 | * Useful for passing custom props to a usage of Tooltip that you don't directly control. |
| b69ab31 | | | 37 | */ |
| b69ab31 | | | 38 | export type TooltipProps = { |
| b69ab31 | | | 39 | inline?: boolean; |
| b69ab31 | | | 40 | placement?: Placement; |
| b69ab31 | | | 41 | /** |
| b69ab31 | | | 42 | * Applies delay to visual appearance of tooltip. |
| b69ab31 | | | 43 | * Note element is always constructed immediately. |
| b69ab31 | | | 44 | * This delay applies to all trigger methods except 'click'. |
| b69ab31 | | | 45 | * The delay is only on the leading-edge; disappearing is always instant. |
| b69ab31 | | | 46 | */ |
| b69ab31 | | | 47 | delayMs?: number; |
| b69ab31 | | | 48 | /** |
| b69ab31 | | | 49 | * When true, the tooltip stays visible when the mouse moves from the trigger |
| b69ab31 | | | 50 | * element onto the tooltip content itself. This allows users to interact with |
| b69ab31 | | | 51 | * the tooltip content, such as selecting and copying text. |
| b69ab31 | | | 52 | * Only applies to hover-triggered tooltips with a `component` prop. |
| b69ab31 | | | 53 | */ |
| b69ab31 | | | 54 | interactive?: boolean; |
| b69ab31 | | | 55 | /** |
| b69ab31 | | | 56 | * Callback to run when the tooltip becomes visible. |
| b69ab31 | | | 57 | * For 'click' tooltips that also have a 'title', this only fires when the 'click' tooltip is shown. |
| b69ab31 | | | 58 | */ |
| b69ab31 | | | 59 | onVisible?: () => unknown; |
| b69ab31 | | | 60 | /** |
| b69ab31 | | | 61 | * Callback to run when the tooltip is dismissed for any reason. |
| b69ab31 | | | 62 | * For 'click' tooltips that also have a 'title', this only fires when the 'click' tooltip is dismissed. |
| b69ab31 | | | 63 | * Note: `onDismiss` will not run if the entire <Tooltip> is unmounted while the tooltip is visible. |
| b69ab31 | | | 64 | */ |
| b69ab31 | | | 65 | onDismiss?: () => unknown; |
| b69ab31 | | | 66 | } & ExclusiveOr< |
| b69ab31 | | | 67 | ExclusiveOr<{trigger: 'manual'; shouldShow: boolean}, {trigger?: 'hover' | 'disabled'}> & |
| b69ab31 | | | 68 | ExclusiveOr< |
| b69ab31 | | | 69 | {component: (dismiss: () => void) => JSX.Element}, |
| b69ab31 | | | 70 | {title: string | React.ReactNode} |
| b69ab31 | | | 71 | >, |
| b69ab31 | | | 72 | { |
| b69ab31 | | | 73 | trigger: 'click'; |
| b69ab31 | | | 74 | component: (dismiss: () => void) => JSX.Element; |
| b69ab31 | | | 75 | title?: string | React.ReactNode; |
| b69ab31 | | | 76 | /** If provided, opening this tooltip will close all other tooltips using the same group */ |
| b69ab31 | | | 77 | group?: string; |
| b69ab31 | | | 78 | /** Event Emitter that allows external callers to toggle this tooltip. |
| b69ab31 | | | 79 | * See also `shared/TypedEventEmitter`'s .asEventTarget() to get a typed API. */ |
| b69ab31 | | | 80 | additionalToggles?: EventTarget; |
| b69ab31 | | | 81 | } |
| b69ab31 | | | 82 | >; |
| b69ab31 | | | 83 | |
| b69ab31 | | | 84 | type TooltipPropsWithChildren = {children: ReactNode} & TooltipProps; |
| b69ab31 | | | 85 | |
| b69ab31 | | | 86 | type VisibleState = |
| b69ab31 | | | 87 | | true /* primary content (prefers component) is visible */ |
| b69ab31 | | | 88 | | false |
| b69ab31 | | | 89 | | 'title' /* 'title', not 'component' is visible */; |
| b69ab31 | | | 90 | |
| b69ab31 | | | 91 | class TooltipChangeEvent<T> extends Event { |
| b69ab31 | | | 92 | constructor( |
| b69ab31 | | | 93 | type: string, |
| b69ab31 | | | 94 | public data: T | Error, |
| b69ab31 | | | 95 | ) { |
| b69ab31 | | | 96 | super(type); |
| b69ab31 | | | 97 | } |
| b69ab31 | | | 98 | } |
| b69ab31 | | | 99 | |
| b69ab31 | | | 100 | /** |
| b69ab31 | | | 101 | * Enables child elements to render a tooltip when hovered/clicked. |
| b69ab31 | | | 102 | * Children are always rendered, but the tooltip is not rendered until triggered. |
| b69ab31 | | | 103 | * Tooltip is centered on bounding box of children. |
| b69ab31 | | | 104 | * You can adjust the trigger method: |
| b69ab31 | | | 105 | * - 'hover' (default) to appear when mouse hovers container element |
| b69ab31 | | | 106 | * - 'click' to render `component` on click, render `title` on hover. |
| b69ab31 | | | 107 | * - 'manual' to control programmatically by providing `shouldShow` prop. |
| b69ab31 | | | 108 | * - 'disabled' to turn off hover/click support programmatically |
| b69ab31 | | | 109 | * |
| b69ab31 | | | 110 | * You can adjust which side the tooltip appears on. |
| b69ab31 | | | 111 | * Default placement is 'top', above the element. |
| b69ab31 | | | 112 | * |
| b69ab31 | | | 113 | * Tooltip content may either be a (i18n-ized) string `title`, or a `Component` to render. |
| b69ab31 | | | 114 | * `title`s will automatically wrap text, |
| b69ab31 | | | 115 | * but `Component`s are expected to handle their own sizing. |
| b69ab31 | | | 116 | * `Component`-rendered content allows pointer events inside the tooltip, |
| b69ab31 | | | 117 | * but string `title`s do not allow pointer events, and dismiss if the mouse exits |
| b69ab31 | | | 118 | * the original tooltip creator. |
| b69ab31 | | | 119 | * |
| b69ab31 | | | 120 | * Tooltips will hide themselves when you scroll or resize. |
| b69ab31 | | | 121 | * This applies even to manual tooltips with shouldShow=true. |
| b69ab31 | | | 122 | */ |
| b69ab31 | | | 123 | export function Tooltip({ |
| b69ab31 | | | 124 | children, |
| b69ab31 | | | 125 | title, |
| b69ab31 | | | 126 | component, |
| b69ab31 | | | 127 | placement: placementProp, |
| b69ab31 | | | 128 | trigger: triggerProp, |
| b69ab31 | | | 129 | delayMs, |
| b69ab31 | | | 130 | shouldShow, |
| b69ab31 | | | 131 | onVisible, |
| b69ab31 | | | 132 | onDismiss, |
| b69ab31 | | | 133 | additionalToggles, |
| b69ab31 | | | 134 | group, |
| b69ab31 | | | 135 | inline: inlineProp, |
| b69ab31 | | | 136 | interactive, |
| b69ab31 | | | 137 | }: TooltipPropsWithChildren) { |
| b69ab31 | | | 138 | const inline = inlineProp ?? false; |
| b69ab31 | | | 139 | const trigger = triggerProp ?? 'hover'; |
| b69ab31 | | | 140 | const placement = placementProp ?? 'top'; |
| b69ab31 | | | 141 | const [visible, setVisible] = useState<VisibleState>(false); |
| b69ab31 | | | 142 | const isHoveringTooltipContent = useRef(false); |
| b69ab31 | | | 143 | |
| b69ab31 | | | 144 | // trigger onDismiss when visibility newly becomes false |
| b69ab31 | | | 145 | const lastVisible = useRef(false); |
| b69ab31 | | | 146 | useEffect(() => { |
| b69ab31 | | | 147 | if (!visible && lastVisible.current === true) { |
| b69ab31 | | | 148 | onDismiss?.(); |
| b69ab31 | | | 149 | } |
| b69ab31 | | | 150 | lastVisible.current = visible === true; |
| b69ab31 | | | 151 | }, [visible, onDismiss, lastVisible]); |
| b69ab31 | | | 152 | |
| b69ab31 | | | 153 | // trigger onVisible when visibility newly becomes true |
| b69ab31 | | | 154 | useEffect(() => { |
| b69ab31 | | | 155 | if (visible === true) { |
| b69ab31 | | | 156 | onVisible?.(); |
| b69ab31 | | | 157 | } |
| b69ab31 | | | 158 | }, [visible, onVisible]); |
| b69ab31 | | | 159 | |
| b69ab31 | | | 160 | const ref = useRef<HTMLDivElement>(null); |
| b69ab31 | | | 161 | const getContent = () => { |
| b69ab31 | | | 162 | if (visible === 'title') { |
| b69ab31 | | | 163 | return title; |
| b69ab31 | | | 164 | } |
| b69ab31 | | | 165 | return component == null ? title : component(() => setVisible(false)); |
| b69ab31 | | | 166 | }; |
| b69ab31 | | | 167 | |
| b69ab31 | | | 168 | useEffect(() => { |
| b69ab31 | | | 169 | if (typeof shouldShow === 'boolean') { |
| b69ab31 | | | 170 | setVisible(shouldShow); |
| b69ab31 | | | 171 | } |
| b69ab31 | | | 172 | }, [shouldShow]); |
| b69ab31 | | | 173 | |
| b69ab31 | | | 174 | useEffect(() => { |
| b69ab31 | | | 175 | if (trigger === 'click') { |
| b69ab31 | | | 176 | if (visible) { |
| b69ab31 | | | 177 | // When using click trigger, we need to listen for clicks outside the tooltip |
| b69ab31 | | | 178 | // to close it again. |
| b69ab31 | | | 179 | const globalClickHandler = (e: Event) => { |
| b69ab31 | | | 180 | if (!eventIsFromInsideTooltip(e as unknown as MouseEvent)) { |
| b69ab31 | | | 181 | setVisible(false); |
| b69ab31 | | | 182 | } |
| b69ab31 | | | 183 | }; |
| b69ab31 | | | 184 | window.addEventListener('click', globalClickHandler); |
| b69ab31 | | | 185 | return () => window.removeEventListener('click', globalClickHandler); |
| b69ab31 | | | 186 | } |
| b69ab31 | | | 187 | } |
| b69ab31 | | | 188 | }, [visible, setVisible, trigger]); |
| b69ab31 | | | 189 | |
| b69ab31 | | | 190 | useEffect(() => { |
| b69ab31 | | | 191 | const cb = () => setVisible(last => !last); |
| b69ab31 | | | 192 | additionalToggles?.addEventListener('change', cb); |
| b69ab31 | | | 193 | return () => { |
| b69ab31 | | | 194 | additionalToggles?.removeEventListener('change', cb); |
| b69ab31 | | | 195 | }; |
| b69ab31 | | | 196 | }, [additionalToggles]); |
| b69ab31 | | | 197 | |
| b69ab31 | | | 198 | useEffect(() => { |
| b69ab31 | | | 199 | if (group) { |
| b69ab31 | | | 200 | const hide = (event: Event) => { |
| b69ab31 | | | 201 | if ((event as TooltipChangeEvent<HTMLElement>).data === ref.current) { |
| b69ab31 | | | 202 | // don't hide the tooltip we're trying to show right now |
| b69ab31 | | | 203 | return; |
| b69ab31 | | | 204 | } |
| b69ab31 | | | 205 | setVisible(false); |
| b69ab31 | | | 206 | }; |
| b69ab31 | | | 207 | const found = getTooltipGroup(group); |
| b69ab31 | | | 208 | found.addEventListener('change', hide); |
| b69ab31 | | | 209 | return () => { |
| b69ab31 | | | 210 | found?.removeEventListener('change', hide); |
| b69ab31 | | | 211 | }; |
| b69ab31 | | | 212 | } |
| b69ab31 | | | 213 | }, [group]); |
| b69ab31 | | | 214 | |
| b69ab31 | | | 215 | // scrolling or resizing the window should hide all tooltips to prevent lingering. |
| b69ab31 | | | 216 | useEffect(() => { |
| b69ab31 | | | 217 | if (visible) { |
| b69ab31 | | | 218 | const hideTooltip = (e: Event) => { |
| b69ab31 | | | 219 | if (e.type === 'keyup') { |
| b69ab31 | | | 220 | if ((e as KeyboardEvent).key === 'Escape') { |
| b69ab31 | | | 221 | setVisible(false); |
| b69ab31 | | | 222 | } |
| b69ab31 | | | 223 | } else if (e.type === 'resize' || !eventIsFromInsideTooltip(e as unknown as MouseEvent)) { |
| b69ab31 | | | 224 | setVisible(false); |
| b69ab31 | | | 225 | } |
| b69ab31 | | | 226 | }; |
| b69ab31 | | | 227 | window.addEventListener('scroll', hideTooltip, true); |
| b69ab31 | | | 228 | window.addEventListener('resize', hideTooltip, true); |
| b69ab31 | | | 229 | window.addEventListener('keyup', hideTooltip, true); |
| b69ab31 | | | 230 | return () => { |
| b69ab31 | | | 231 | window.removeEventListener('scroll', hideTooltip, true); |
| b69ab31 | | | 232 | window.removeEventListener('resize', hideTooltip, true); |
| b69ab31 | | | 233 | window.removeEventListener('keyup', hideTooltip, true); |
| b69ab31 | | | 234 | }; |
| b69ab31 | | | 235 | } |
| b69ab31 | | | 236 | }, [visible, setVisible]); |
| b69ab31 | | | 237 | |
| b69ab31 | | | 238 | // Using onMouseLeave directly on the div is unreliable if the component rerenders: https://github.com/facebook/react/issues/4492 |
| b69ab31 | | | 239 | // Use a manually managed subscription instead. |
| b69ab31 | | | 240 | useLayoutEffect(() => { |
| b69ab31 | | | 241 | const needHover = trigger === 'hover' || (trigger === 'click' && title != null); |
| b69ab31 | | | 242 | if (!needHover) { |
| b69ab31 | | | 243 | return; |
| b69ab31 | | | 244 | } |
| b69ab31 | | | 245 | // Do not change visible if 'click' shows the content. |
| b69ab31 | | | 246 | const onMouseEnter = () => |
| b69ab31 | | | 247 | setVisible(vis => (trigger === 'click' ? (vis === true ? vis : 'title') : true)); |
| b69ab31 | | | 248 | const onMouseLeave = () => { |
| b69ab31 | | | 249 | // For interactive tooltips, delay hiding to allow mouse to move to tooltip content |
| b69ab31 | | | 250 | if (interactive && component != null) { |
| b69ab31 | | | 251 | setTimeout(() => { |
| b69ab31 | | | 252 | if (!isHoveringTooltipContent.current) { |
| b69ab31 | | | 253 | setVisible(vis => (trigger === 'click' && vis === true ? vis : false)); |
| b69ab31 | | | 254 | } |
| b69ab31 | | | 255 | }, 100); |
| b69ab31 | | | 256 | } else { |
| b69ab31 | | | 257 | setVisible(vis => (trigger === 'click' && vis === true ? vis : false)); |
| b69ab31 | | | 258 | } |
| b69ab31 | | | 259 | }; |
| b69ab31 | | | 260 | const div = ref.current; |
| b69ab31 | | | 261 | div?.addEventListener('mouseenter', onMouseEnter); |
| b69ab31 | | | 262 | div?.addEventListener('mouseleave', onMouseLeave); |
| b69ab31 | | | 263 | return () => { |
| b69ab31 | | | 264 | div?.removeEventListener('mouseenter', onMouseEnter); |
| b69ab31 | | | 265 | div?.removeEventListener('mouseleave', onMouseLeave); |
| b69ab31 | | | 266 | }; |
| b69ab31 | | | 267 | }, [trigger, title, interactive, component]); |
| b69ab31 | | | 268 | |
| b69ab31 | | | 269 | // Force delayMs to be 0 when `component` is shown by click. |
| b69ab31 | | | 270 | const realDelayMs = trigger === 'click' && visible === true ? 0 : delayMs; |
| b69ab31 | | | 271 | |
| b69ab31 | | | 272 | return ( |
| b69ab31 | | | 273 | <div |
| b69ab31 | | | 274 | className={inline ? 'tooltip-creator-inline' : 'tooltip-creator'} |
| b69ab31 | | | 275 | ref={ref} |
| b69ab31 | | | 276 | onClick={ |
| b69ab31 | | | 277 | trigger === 'click' |
| b69ab31 | | | 278 | ? (event: MouseEvent) => { |
| b69ab31 | | | 279 | if (visible !== true || !eventIsFromInsideTooltip(event)) { |
| b69ab31 | | | 280 | if (group != null) { |
| b69ab31 | | | 281 | // close other tooltips in the same group before opening |
| b69ab31 | | | 282 | const found = getTooltipGroup(group); |
| b69ab31 | | | 283 | found.dispatchEvent(new TooltipChangeEvent('change', ref.current)); |
| b69ab31 | | | 284 | } |
| b69ab31 | | | 285 | setVisible(vis => vis !== true); |
| b69ab31 | | | 286 | // don't trigger global click listener in the same tick |
| b69ab31 | | | 287 | event.stopPropagation(); |
| b69ab31 | | | 288 | } |
| b69ab31 | | | 289 | } |
| b69ab31 | | | 290 | : undefined |
| b69ab31 | | | 291 | }> |
| b69ab31 | | | 292 | {visible && ref.current && ( |
| b69ab31 | | | 293 | <RenderTooltipOnto |
| b69ab31 | | | 294 | delayMs={realDelayMs} |
| b69ab31 | | | 295 | element={ref.current} |
| b69ab31 | | | 296 | placement={placement} |
| b69ab31 | | | 297 | interactive={interactive} |
| b69ab31 | | | 298 | onTooltipMouseEnter={() => { |
| b69ab31 | | | 299 | isHoveringTooltipContent.current = true; |
| b69ab31 | | | 300 | }} |
| b69ab31 | | | 301 | onTooltipMouseLeave={() => { |
| b69ab31 | | | 302 | isHoveringTooltipContent.current = false; |
| b69ab31 | | | 303 | if (trigger === 'hover') { |
| b69ab31 | | | 304 | setVisible(false); |
| b69ab31 | | | 305 | } |
| b69ab31 | | | 306 | }}> |
| b69ab31 | | | 307 | {getContent()} |
| b69ab31 | | | 308 | </RenderTooltipOnto> |
| b69ab31 | | | 309 | )} |
| b69ab31 | | | 310 | {children} |
| b69ab31 | | | 311 | </div> |
| b69ab31 | | | 312 | ); |
| b69ab31 | | | 313 | } |
| b69ab31 | | | 314 | |
| b69ab31 | | | 315 | /** |
| b69ab31 | | | 316 | * If you click inside a tooltip triggered by click, we don't want to dismiss the tooltip. |
| b69ab31 | | | 317 | * We consider any click in a descendant of ANY tooltip as a click. |
| b69ab31 | | | 318 | * Same applies for scroll events inside tooltips. |
| b69ab31 | | | 319 | */ |
| b69ab31 | | | 320 | function eventIsFromInsideTooltip(event: MouseEvent): boolean { |
| b69ab31 | | | 321 | const parentTooltip = findParentWithClassName(event.target as HTMLElement, 'tooltip'); |
| b69ab31 | | | 322 | return parentTooltip != null; |
| b69ab31 | | | 323 | } |
| b69ab31 | | | 324 | |
| b69ab31 | | | 325 | function RenderTooltipOnto({ |
| b69ab31 | | | 326 | element, |
| b69ab31 | | | 327 | placement, |
| b69ab31 | | | 328 | children, |
| b69ab31 | | | 329 | delayMs, |
| b69ab31 | | | 330 | interactive, |
| b69ab31 | | | 331 | onTooltipMouseEnter, |
| b69ab31 | | | 332 | onTooltipMouseLeave, |
| b69ab31 | | | 333 | }: { |
| b69ab31 | | | 334 | element: HTMLElement; |
| b69ab31 | | | 335 | placement: Placement; |
| b69ab31 | | | 336 | children: ReactNode; |
| b69ab31 | | | 337 | delayMs?: number; |
| b69ab31 | | | 338 | interactive?: boolean; |
| b69ab31 | | | 339 | onTooltipMouseEnter?: () => void; |
| b69ab31 | | | 340 | onTooltipMouseLeave?: () => void; |
| b69ab31 | | | 341 | }) { |
| b69ab31 | | | 342 | const sourceBoundingRect = element.getBoundingClientRect(); |
| b69ab31 | | | 343 | const tooltipRef = useRef<HTMLDivElement | null>(null); |
| b69ab31 | | | 344 | |
| b69ab31 | | | 345 | const zoom = getZoomLevel(); |
| b69ab31 | | | 346 | let effectivePlacement = placement; |
| b69ab31 | | | 347 | const viewportDimensions = document.body.getBoundingClientRect(); |
| b69ab31 | | | 348 | viewportDimensions.width /= zoom; |
| b69ab31 | | | 349 | viewportDimensions.height /= zoom; |
| b69ab31 | | | 350 | |
| b69ab31 | | | 351 | // to center the tooltip over the tooltip-creator, we need to measure its final rendered size |
| b69ab31 | | | 352 | const renderedDimensions = useRenderedDimensions(tooltipRef, children); |
| b69ab31 | | | 353 | const position = offsetsForPlacement( |
| b69ab31 | | | 354 | placement, |
| b69ab31 | | | 355 | sourceBoundingRect, |
| b69ab31 | | | 356 | renderedDimensions, |
| b69ab31 | | | 357 | viewportDimensions, |
| b69ab31 | | | 358 | ); |
| b69ab31 | | | 359 | effectivePlacement = position.autoPlacement ?? placement; |
| b69ab31 | | | 360 | // The tooltip may end up overflowing off the screen, since it's rendered absolutely. |
| b69ab31 | | | 361 | // We can push it back as needed with an additional offset. |
| b69ab31 | | | 362 | const viewportAdjust = getViewportAdjustedDelta(effectivePlacement, position, renderedDimensions); |
| b69ab31 | | | 363 | |
| b69ab31 | | | 364 | const style: React.CSSProperties = { |
| b69ab31 | | | 365 | animationDelay: delayMs ? `${delayMs}ms` : undefined, |
| b69ab31 | | | 366 | }; |
| b69ab31 | | | 367 | |
| b69ab31 | | | 368 | const pad = 10; |
| b69ab31 | | | 369 | |
| b69ab31 | | | 370 | if (position.left > viewportDimensions.width / 2) { |
| b69ab31 | | | 371 | // All our position computations use top+left. |
| b69ab31 | | | 372 | // If we position using `left`, but the tooltip is near the right edge, |
| b69ab31 | | | 373 | // it will squish itself to fit rather than push itself further left. |
| b69ab31 | | | 374 | // Instead, we need to position with `right`, computed from left. based on the viewport dimension. |
| b69ab31 | | | 375 | style.right = |
| b69ab31 | | | 376 | viewportDimensions.width - (position.left + viewportAdjust.left + renderedDimensions.width); |
| b69ab31 | | | 377 | } else { |
| b69ab31 | | | 378 | style.left = position.left + viewportAdjust.left; |
| b69ab31 | | | 379 | } |
| b69ab31 | | | 380 | // Note: The same could technically apply for top/bottom, but only for left/right positioned tooltips which are less common, |
| b69ab31 | | | 381 | // so in practice it matters less. |
| b69ab31 | | | 382 | if (position.top > viewportDimensions.height / 2) { |
| b69ab31 | | | 383 | style.bottom = |
| b69ab31 | | | 384 | viewportDimensions.height - (position.top + viewportAdjust.top + renderedDimensions.height); |
| b69ab31 | | | 385 | style.maxHeight = viewportDimensions.height - style.bottom - 3 * pad; |
| b69ab31 | | | 386 | } else { |
| b69ab31 | | | 387 | style.top = position.top + viewportAdjust.top; |
| b69ab31 | | | 388 | style.maxHeight = viewportDimensions.height - style.top - 3 * pad; |
| b69ab31 | | | 389 | } |
| b69ab31 | | | 390 | style.height = |
| b69ab31 | | | 391 | renderedDimensions.height > style.maxHeight |
| b69ab31 | | | 392 | ? '100%' // allow scrolling |
| b69ab31 | | | 393 | : 'unset'; |
| b69ab31 | | | 394 | |
| b69ab31 | | | 395 | // Use a portal so the tooltip element is rendered into the global list of tooltips, |
| b69ab31 | | | 396 | // rather than as a descendant of the tooltip creator. |
| b69ab31 | | | 397 | // This allows us to use absolute coordinates for positioning, and for |
| b69ab31 | | | 398 | // tooltips to "escape" their containing elements, scroll, inherited styles, etc. |
| b69ab31 | | | 399 | return ( |
| b69ab31 | | | 400 | <ViewportOverlay> |
| b69ab31 | | | 401 | <div |
| b69ab31 | | | 402 | ref={tooltipRef} |
| b69ab31 | | | 403 | role="tooltip" |
| b69ab31 | | | 404 | className={ |
| b69ab31 | | | 405 | `tooltip tooltip-${effectivePlacement}` + |
| b69ab31 | | | 406 | (typeof children === 'string' ? ' simple-text-tooltip' : '') |
| b69ab31 | | | 407 | } |
| b69ab31 | | | 408 | style={style} |
| b69ab31 | | | 409 | onMouseEnter={interactive ? onTooltipMouseEnter : undefined} |
| b69ab31 | | | 410 | onMouseLeave={interactive ? onTooltipMouseLeave : undefined}> |
| b69ab31 | | | 411 | <div |
| b69ab31 | | | 412 | className={`tooltip-arrow tooltip-arrow-${effectivePlacement}`} |
| b69ab31 | | | 413 | // If we had to push the tooltip back to prevent overflow, |
| b69ab31 | | | 414 | // we also need to move the arrow the opposite direction so it still lines up. |
| b69ab31 | | | 415 | style={{transform: `translate(${-viewportAdjust.left}px, ${-viewportAdjust.top}px)`}} |
| b69ab31 | | | 416 | /> |
| b69ab31 | | | 417 | {children} |
| b69ab31 | | | 418 | </div> |
| b69ab31 | | | 419 | </ViewportOverlay> |
| b69ab31 | | | 420 | ); |
| b69ab31 | | | 421 | } |
| b69ab31 | | | 422 | |
| b69ab31 | | | 423 | type OffsetPlacement = { |
| b69ab31 | | | 424 | top: number; |
| b69ab31 | | | 425 | left: number; |
| b69ab31 | | | 426 | autoPlacement?: Placement; |
| b69ab31 | | | 427 | }; |
| b69ab31 | | | 428 | |
| b69ab31 | | | 429 | /** |
| b69ab31 | | | 430 | * Offset tooltip from tooltipCreator's absolute position using `placement`, |
| b69ab31 | | | 431 | * such that it is centered and on the correct side. |
| b69ab31 | | | 432 | * This requires the rendered tooltip's width and height (for centering). |
| b69ab31 | | | 433 | * Coordinates are left&top absolute offsets. |
| b69ab31 | | | 434 | * |
| b69ab31 | | | 435 | * When appropriate, we also detect if this placement would go offscreen |
| b69ab31 | | | 436 | * and instead provide a better placement. |
| b69ab31 | | | 437 | * |
| b69ab31 | | | 438 | * In this diagram, `0` is `tooltipCreatorRect`, |
| b69ab31 | | | 439 | * `1` is what we want to compute using 0 and the size of the rendered tooltip. |
| b69ab31 | | | 440 | * |
| b69ab31 | | | 441 | * 0---* |
| b69ab31 | | | 442 | * | | <- tooltip creator (The thing you hover to see the tooltip) |
| b69ab31 | | | 443 | * *---* |
| b69ab31 | | | 444 | * ^ <- tooltip arrow |
| b69ab31 | | | 445 | * 1---------+ |
| b69ab31 | | | 446 | * | | <- tooltip |
| b69ab31 | | | 447 | * +---------+ |
| b69ab31 | | | 448 | * |
| b69ab31 | | | 449 | */ |
| b69ab31 | | | 450 | function offsetsForPlacement( |
| b69ab31 | | | 451 | placement: Placement, |
| b69ab31 | | | 452 | tooltipCreatorRect: DOMRect, |
| b69ab31 | | | 453 | tooltipDimensions: {width: number; height: number}, |
| b69ab31 | | | 454 | viewportDimensions: DOMRect, |
| b69ab31 | | | 455 | ): OffsetPlacement { |
| b69ab31 | | | 456 | const padding = 5; |
| b69ab31 | | | 457 | let result: OffsetPlacement = {top: 0, left: 0}; |
| b69ab31 | | | 458 | let currentPlacement = placement; |
| b69ab31 | | | 459 | for (let i = 0; i <= 2; i++) { |
| b69ab31 | | | 460 | switch (currentPlacement) { |
| b69ab31 | | | 461 | case 'top': { |
| b69ab31 | | | 462 | result = { |
| b69ab31 | | | 463 | top: tooltipCreatorRect.top - padding - tooltipDimensions.height, |
| b69ab31 | | | 464 | left: |
| b69ab31 | | | 465 | tooltipCreatorRect.left + tooltipCreatorRect.width / 2 - tooltipDimensions.width / 2, |
| b69ab31 | | | 466 | }; |
| b69ab31 | | | 467 | if (result.top < 0) { |
| b69ab31 | | | 468 | currentPlacement = 'bottom'; |
| b69ab31 | | | 469 | continue; |
| b69ab31 | | | 470 | } |
| b69ab31 | | | 471 | break; |
| b69ab31 | | | 472 | } |
| b69ab31 | | | 473 | case 'bottom': { |
| b69ab31 | | | 474 | result = { |
| b69ab31 | | | 475 | top: tooltipCreatorRect.top + tooltipCreatorRect.height + padding, |
| b69ab31 | | | 476 | left: |
| b69ab31 | | | 477 | tooltipCreatorRect.left + tooltipCreatorRect.width / 2 - tooltipDimensions.width / 2, |
| b69ab31 | | | 478 | }; |
| b69ab31 | | | 479 | if (result.top + tooltipDimensions.height > viewportDimensions.height) { |
| b69ab31 | | | 480 | currentPlacement = 'top'; |
| b69ab31 | | | 481 | continue; |
| b69ab31 | | | 482 | } |
| b69ab31 | | | 483 | break; |
| b69ab31 | | | 484 | } |
| b69ab31 | | | 485 | case 'left': { |
| b69ab31 | | | 486 | result = { |
| b69ab31 | | | 487 | top: |
| b69ab31 | | | 488 | tooltipCreatorRect.top + tooltipCreatorRect.height / 2 - tooltipDimensions.height / 2, |
| b69ab31 | | | 489 | left: tooltipCreatorRect.left - tooltipDimensions.width - padding, |
| b69ab31 | | | 490 | }; |
| b69ab31 | | | 491 | if (result.left < 0) { |
| b69ab31 | | | 492 | currentPlacement = 'right'; |
| b69ab31 | | | 493 | continue; |
| b69ab31 | | | 494 | } |
| b69ab31 | | | 495 | break; |
| b69ab31 | | | 496 | } |
| b69ab31 | | | 497 | case 'right': { |
| b69ab31 | | | 498 | result = { |
| b69ab31 | | | 499 | top: |
| b69ab31 | | | 500 | tooltipCreatorRect.top + tooltipCreatorRect.height / 2 - tooltipDimensions.height / 2, |
| b69ab31 | | | 501 | left: tooltipCreatorRect.right + padding, |
| b69ab31 | | | 502 | }; |
| b69ab31 | | | 503 | if (result.left + tooltipDimensions.width > viewportDimensions.width) { |
| b69ab31 | | | 504 | currentPlacement = 'left'; |
| b69ab31 | | | 505 | continue; |
| b69ab31 | | | 506 | } |
| b69ab31 | | | 507 | break; |
| b69ab31 | | | 508 | } |
| b69ab31 | | | 509 | } |
| b69ab31 | | | 510 | break; |
| b69ab31 | | | 511 | } |
| b69ab31 | | | 512 | // Set autoPlacement if we chose a different placement. |
| b69ab31 | | | 513 | if (currentPlacement !== placement) { |
| b69ab31 | | | 514 | result.autoPlacement = currentPlacement; |
| b69ab31 | | | 515 | } |
| b69ab31 | | | 516 | return result; |
| b69ab31 | | | 517 | } |
| b69ab31 | | | 518 | |
| b69ab31 | | | 519 | /** |
| b69ab31 | | | 520 | * If the rendered tooltip would overflow outside the screen bounds, |
| b69ab31 | | | 521 | * we need to translate the tooltip back into bounds. |
| b69ab31 | | | 522 | */ |
| b69ab31 | | | 523 | function getViewportAdjustedDelta( |
| b69ab31 | | | 524 | placement: Placement, |
| b69ab31 | | | 525 | pos: {top: number; left: number}, |
| b69ab31 | | | 526 | renderedDimensions: {width: number; height: number}, |
| b69ab31 | | | 527 | ): {left: number; top: number} { |
| b69ab31 | | | 528 | const delta = {top: 0, left: 0}; |
| b69ab31 | | | 529 | |
| b69ab31 | | | 530 | const viewportPadding = 5; |
| b69ab31 | | | 531 | const viewportDimensions = document.body.getBoundingClientRect(); |
| b69ab31 | | | 532 | |
| b69ab31 | | | 533 | const zoom = getZoomLevel(); |
| b69ab31 | | | 534 | viewportDimensions.width /= zoom; |
| b69ab31 | | | 535 | viewportDimensions.height /= zoom; |
| b69ab31 | | | 536 | |
| b69ab31 | | | 537 | if (placement === 'right' || placement === 'left') { |
| b69ab31 | | | 538 | const topEdgeOffset = pos.top - viewportPadding; |
| b69ab31 | | | 539 | const bottomEdgeOffset = pos.top + viewportPadding + renderedDimensions.height; |
| b69ab31 | | | 540 | if (topEdgeOffset < viewportDimensions.top) { |
| b69ab31 | | | 541 | // top overflow |
| b69ab31 | | | 542 | delta.top = viewportDimensions.top - topEdgeOffset; |
| b69ab31 | | | 543 | } else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { |
| b69ab31 | | | 544 | // bottom overflow |
| b69ab31 | | | 545 | delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset; |
| b69ab31 | | | 546 | } |
| b69ab31 | | | 547 | } else { |
| b69ab31 | | | 548 | const leftEdgeOffset = pos.left - viewportPadding; |
| b69ab31 | | | 549 | const rightEdgeOffset = pos.left + viewportPadding + renderedDimensions.width; |
| b69ab31 | | | 550 | if (leftEdgeOffset < viewportDimensions.left) { |
| b69ab31 | | | 551 | // left overflow |
| b69ab31 | | | 552 | delta.left = viewportDimensions.left - leftEdgeOffset; |
| b69ab31 | | | 553 | } else if (rightEdgeOffset > viewportDimensions.right) { |
| b69ab31 | | | 554 | // right overflow |
| b69ab31 | | | 555 | delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset; |
| b69ab31 | | | 556 | } |
| b69ab31 | | | 557 | } |
| b69ab31 | | | 558 | |
| b69ab31 | | | 559 | return delta; |
| b69ab31 | | | 560 | } |
| b69ab31 | | | 561 | |
| b69ab31 | | | 562 | function useRenderedDimensions(ref: React.MutableRefObject<HTMLDivElement | null>, deps: unknown) { |
| b69ab31 | | | 563 | const [dimensions, setDimensions] = useState({width: 0, height: 0}); |
| b69ab31 | | | 564 | |
| b69ab31 | | | 565 | useLayoutEffect(() => { |
| b69ab31 | | | 566 | const target = ref.current; |
| b69ab31 | | | 567 | if (target == null) { |
| b69ab31 | | | 568 | return; |
| b69ab31 | | | 569 | } |
| b69ab31 | | | 570 | |
| b69ab31 | | | 571 | const updateDimensions = () => { |
| b69ab31 | | | 572 | setDimensions({ |
| b69ab31 | | | 573 | width: target.offsetWidth, |
| b69ab31 | | | 574 | height: target.offsetHeight, |
| b69ab31 | | | 575 | }); |
| b69ab31 | | | 576 | }; |
| b69ab31 | | | 577 | |
| b69ab31 | | | 578 | updateDimensions(); |
| b69ab31 | | | 579 | |
| b69ab31 | | | 580 | const observer = new ResizeObserver(entries => { |
| b69ab31 | | | 581 | entries.forEach(entry => { |
| b69ab31 | | | 582 | if (entry.target === target) { |
| b69ab31 | | | 583 | updateDimensions(); |
| b69ab31 | | | 584 | } |
| b69ab31 | | | 585 | }); |
| b69ab31 | | | 586 | }); |
| b69ab31 | | | 587 | |
| b69ab31 | | | 588 | // Children might resize without re-rendering the tooltip. |
| b69ab31 | | | 589 | // Observe that and trigger re-positioning. |
| b69ab31 | | | 590 | // Unlike useLayoutEffect, the ResizeObserver does not prevent |
| b69ab31 | | | 591 | // rendering the old state. |
| b69ab31 | | | 592 | observer.observe(target); |
| b69ab31 | | | 593 | return () => observer.disconnect(); |
| b69ab31 | | | 594 | }, [ref, deps]); |
| b69ab31 | | | 595 | |
| b69ab31 | | | 596 | return dimensions; |
| b69ab31 | | | 597 | } |