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