addons/components/Tooltip.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {MouseEvent, ReactNode} from 'react';
b69ab319import type {ExclusiveOr} from './Types';
b69ab3110
b69ab3111import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
b69ab3112import {ViewportOverlay} from './ViewportOverlay';
b69ab3113import {findParentWithClassName} from './utils';
b69ab3114import {getZoomLevel} from './zoom';
b69ab3115
b69ab3116import './Tooltip.css';
b69ab3117
b69ab3118export type Placement = 'top' | 'bottom' | 'left' | 'right';
b69ab3119
b69ab3120/**
b69ab3121 * Default delay used for hover tooltips to convey documentation information.
b69ab3122 */
b69ab3123export const DOCUMENTATION_DELAY = 750;
b69ab3124
b69ab3125const tooltipGroups: Map<string, EventTarget> = new Map();
b69ab3126function getTooltipGroup(group: string): EventTarget {
b69ab3127 let found = tooltipGroups.get(group);
b69ab3128 if (found == null) {
b69ab3129 found = new EventTarget();
b69ab3130 tooltipGroups.set(group, found);
b69ab3131 }
b69ab3132 return found;
b69ab3133}
b69ab3134
b69ab3135/**
b69ab3136 * Useful for passing custom props to a usage of Tooltip that you don't directly control.
b69ab3137 */
b69ab3138export type TooltipProps = {
b69ab3139 inline?: boolean;
b69ab3140 placement?: Placement;
b69ab3141 /**
b69ab3142 * Applies delay to visual appearance of tooltip.
b69ab3143 * Note element is always constructed immediately.
b69ab3144 * This delay applies to all trigger methods except 'click'.
b69ab3145 * The delay is only on the leading-edge; disappearing is always instant.
b69ab3146 */
b69ab3147 delayMs?: number;
b69ab3148 /**
b69ab3149 * When true, the tooltip stays visible when the mouse moves from the trigger
b69ab3150 * element onto the tooltip content itself. This allows users to interact with
b69ab3151 * the tooltip content, such as selecting and copying text.
b69ab3152 * Only applies to hover-triggered tooltips with a `component` prop.
b69ab3153 */
b69ab3154 interactive?: boolean;
b69ab3155 /**
b69ab3156 * Callback to run when the tooltip becomes visible.
b69ab3157 * For 'click' tooltips that also have a 'title', this only fires when the 'click' tooltip is shown.
b69ab3158 */
b69ab3159 onVisible?: () => unknown;
b69ab3160 /**
b69ab3161 * Callback to run when the tooltip is dismissed for any reason.
b69ab3162 * For 'click' tooltips that also have a 'title', this only fires when the 'click' tooltip is dismissed.
b69ab3163 * Note: `onDismiss` will not run if the entire <Tooltip> is unmounted while the tooltip is visible.
b69ab3164 */
b69ab3165 onDismiss?: () => unknown;
b69ab3166} & ExclusiveOr<
b69ab3167 ExclusiveOr<{trigger: 'manual'; shouldShow: boolean}, {trigger?: 'hover' | 'disabled'}> &
b69ab3168 ExclusiveOr<
b69ab3169 {component: (dismiss: () => void) => JSX.Element},
b69ab3170 {title: string | React.ReactNode}
b69ab3171 >,
b69ab3172 {
b69ab3173 trigger: 'click';
b69ab3174 component: (dismiss: () => void) => JSX.Element;
b69ab3175 title?: string | React.ReactNode;
b69ab3176 /** If provided, opening this tooltip will close all other tooltips using the same group */
b69ab3177 group?: string;
b69ab3178 /** Event Emitter that allows external callers to toggle this tooltip.
b69ab3179 * See also `shared/TypedEventEmitter`'s .asEventTarget() to get a typed API. */
b69ab3180 additionalToggles?: EventTarget;
b69ab3181 }
b69ab3182>;
b69ab3183
b69ab3184type TooltipPropsWithChildren = {children: ReactNode} & TooltipProps;
b69ab3185
b69ab3186type VisibleState =
b69ab3187 | true /* primary content (prefers component) is visible */
b69ab3188 | false
b69ab3189 | 'title' /* 'title', not 'component' is visible */;
b69ab3190
b69ab3191class TooltipChangeEvent<T> extends Event {
b69ab3192 constructor(
b69ab3193 type: string,
b69ab3194 public data: T | Error,
b69ab3195 ) {
b69ab3196 super(type);
b69ab3197 }
b69ab3198}
b69ab3199
b69ab31100/**
b69ab31101 * Enables child elements to render a tooltip when hovered/clicked.
b69ab31102 * Children are always rendered, but the tooltip is not rendered until triggered.
b69ab31103 * Tooltip is centered on bounding box of children.
b69ab31104 * You can adjust the trigger method:
b69ab31105 * - 'hover' (default) to appear when mouse hovers container element
b69ab31106 * - 'click' to render `component` on click, render `title` on hover.
b69ab31107 * - 'manual' to control programmatically by providing `shouldShow` prop.
b69ab31108 * - 'disabled' to turn off hover/click support programmatically
b69ab31109 *
b69ab31110 * You can adjust which side the tooltip appears on.
b69ab31111 * Default placement is 'top', above the element.
b69ab31112 *
b69ab31113 * Tooltip content may either be a (i18n-ized) string `title`, or a `Component` to render.
b69ab31114 * `title`s will automatically wrap text,
b69ab31115 * but `Component`s are expected to handle their own sizing.
b69ab31116 * `Component`-rendered content allows pointer events inside the tooltip,
b69ab31117 * but string `title`s do not allow pointer events, and dismiss if the mouse exits
b69ab31118 * the original tooltip creator.
b69ab31119 *
b69ab31120 * Tooltips will hide themselves when you scroll or resize.
b69ab31121 * This applies even to manual tooltips with shouldShow=true.
b69ab31122 */
b69ab31123export function Tooltip({
b69ab31124 children,
b69ab31125 title,
b69ab31126 component,
b69ab31127 placement: placementProp,
b69ab31128 trigger: triggerProp,
b69ab31129 delayMs,
b69ab31130 shouldShow,
b69ab31131 onVisible,
b69ab31132 onDismiss,
b69ab31133 additionalToggles,
b69ab31134 group,
b69ab31135 inline: inlineProp,
b69ab31136 interactive,
b69ab31137}: TooltipPropsWithChildren) {
b69ab31138 const inline = inlineProp ?? false;
b69ab31139 const trigger = triggerProp ?? 'hover';
b69ab31140 const placement = placementProp ?? 'top';
b69ab31141 const [visible, setVisible] = useState<VisibleState>(false);
b69ab31142 const isHoveringTooltipContent = useRef(false);
b69ab31143
b69ab31144 // trigger onDismiss when visibility newly becomes false
b69ab31145 const lastVisible = useRef(false);
b69ab31146 useEffect(() => {
b69ab31147 if (!visible && lastVisible.current === true) {
b69ab31148 onDismiss?.();
b69ab31149 }
b69ab31150 lastVisible.current = visible === true;
b69ab31151 }, [visible, onDismiss, lastVisible]);
b69ab31152
b69ab31153 // trigger onVisible when visibility newly becomes true
b69ab31154 useEffect(() => {
b69ab31155 if (visible === true) {
b69ab31156 onVisible?.();
b69ab31157 }
b69ab31158 }, [visible, onVisible]);
b69ab31159
b69ab31160 const ref = useRef<HTMLDivElement>(null);
b69ab31161 const getContent = () => {
b69ab31162 if (visible === 'title') {
b69ab31163 return title;
b69ab31164 }
b69ab31165 return component == null ? title : component(() => setVisible(false));
b69ab31166 };
b69ab31167
b69ab31168 useEffect(() => {
b69ab31169 if (typeof shouldShow === 'boolean') {
b69ab31170 setVisible(shouldShow);
b69ab31171 }
b69ab31172 }, [shouldShow]);
b69ab31173
b69ab31174 useEffect(() => {
b69ab31175 if (trigger === 'click') {
b69ab31176 if (visible) {
b69ab31177 // When using click trigger, we need to listen for clicks outside the tooltip
b69ab31178 // to close it again.
b69ab31179 const globalClickHandler = (e: Event) => {
b69ab31180 if (!eventIsFromInsideTooltip(e as unknown as MouseEvent)) {
b69ab31181 setVisible(false);
b69ab31182 }
b69ab31183 };
b69ab31184 window.addEventListener('click', globalClickHandler);
b69ab31185 return () => window.removeEventListener('click', globalClickHandler);
b69ab31186 }
b69ab31187 }
b69ab31188 }, [visible, setVisible, trigger]);
b69ab31189
b69ab31190 useEffect(() => {
b69ab31191 const cb = () => setVisible(last => !last);
b69ab31192 additionalToggles?.addEventListener('change', cb);
b69ab31193 return () => {
b69ab31194 additionalToggles?.removeEventListener('change', cb);
b69ab31195 };
b69ab31196 }, [additionalToggles]);
b69ab31197
b69ab31198 useEffect(() => {
b69ab31199 if (group) {
b69ab31200 const hide = (event: Event) => {
b69ab31201 if ((event as TooltipChangeEvent<HTMLElement>).data === ref.current) {
b69ab31202 // don't hide the tooltip we're trying to show right now
b69ab31203 return;
b69ab31204 }
b69ab31205 setVisible(false);
b69ab31206 };
b69ab31207 const found = getTooltipGroup(group);
b69ab31208 found.addEventListener('change', hide);
b69ab31209 return () => {
b69ab31210 found?.removeEventListener('change', hide);
b69ab31211 };
b69ab31212 }
b69ab31213 }, [group]);
b69ab31214
b69ab31215 // scrolling or resizing the window should hide all tooltips to prevent lingering.
b69ab31216 useEffect(() => {
b69ab31217 if (visible) {
b69ab31218 const hideTooltip = (e: Event) => {
b69ab31219 if (e.type === 'keyup') {
b69ab31220 if ((e as KeyboardEvent).key === 'Escape') {
b69ab31221 setVisible(false);
b69ab31222 }
b69ab31223 } else if (e.type === 'resize' || !eventIsFromInsideTooltip(e as unknown as MouseEvent)) {
b69ab31224 setVisible(false);
b69ab31225 }
b69ab31226 };
b69ab31227 window.addEventListener('scroll', hideTooltip, true);
b69ab31228 window.addEventListener('resize', hideTooltip, true);
b69ab31229 window.addEventListener('keyup', hideTooltip, true);
b69ab31230 return () => {
b69ab31231 window.removeEventListener('scroll', hideTooltip, true);
b69ab31232 window.removeEventListener('resize', hideTooltip, true);
b69ab31233 window.removeEventListener('keyup', hideTooltip, true);
b69ab31234 };
b69ab31235 }
b69ab31236 }, [visible, setVisible]);
b69ab31237
b69ab31238 // Using onMouseLeave directly on the div is unreliable if the component rerenders: https://github.com/facebook/react/issues/4492
b69ab31239 // Use a manually managed subscription instead.
b69ab31240 useLayoutEffect(() => {
b69ab31241 const needHover = trigger === 'hover' || (trigger === 'click' && title != null);
b69ab31242 if (!needHover) {
b69ab31243 return;
b69ab31244 }
b69ab31245 // Do not change visible if 'click' shows the content.
b69ab31246 const onMouseEnter = () =>
b69ab31247 setVisible(vis => (trigger === 'click' ? (vis === true ? vis : 'title') : true));
b69ab31248 const onMouseLeave = () => {
b69ab31249 // For interactive tooltips, delay hiding to allow mouse to move to tooltip content
b69ab31250 if (interactive && component != null) {
b69ab31251 setTimeout(() => {
b69ab31252 if (!isHoveringTooltipContent.current) {
b69ab31253 setVisible(vis => (trigger === 'click' && vis === true ? vis : false));
b69ab31254 }
b69ab31255 }, 100);
b69ab31256 } else {
b69ab31257 setVisible(vis => (trigger === 'click' && vis === true ? vis : false));
b69ab31258 }
b69ab31259 };
b69ab31260 const div = ref.current;
b69ab31261 div?.addEventListener('mouseenter', onMouseEnter);
b69ab31262 div?.addEventListener('mouseleave', onMouseLeave);
b69ab31263 return () => {
b69ab31264 div?.removeEventListener('mouseenter', onMouseEnter);
b69ab31265 div?.removeEventListener('mouseleave', onMouseLeave);
b69ab31266 };
b69ab31267 }, [trigger, title, interactive, component]);
b69ab31268
b69ab31269 // Force delayMs to be 0 when `component` is shown by click.
b69ab31270 const realDelayMs = trigger === 'click' && visible === true ? 0 : delayMs;
b69ab31271
b69ab31272 return (
b69ab31273 <div
b69ab31274 className={inline ? 'tooltip-creator-inline' : 'tooltip-creator'}
b69ab31275 ref={ref}
b69ab31276 onClick={
b69ab31277 trigger === 'click'
b69ab31278 ? (event: MouseEvent) => {
b69ab31279 if (visible !== true || !eventIsFromInsideTooltip(event)) {
b69ab31280 if (group != null) {
b69ab31281 // close other tooltips in the same group before opening
b69ab31282 const found = getTooltipGroup(group);
b69ab31283 found.dispatchEvent(new TooltipChangeEvent('change', ref.current));
b69ab31284 }
b69ab31285 setVisible(vis => vis !== true);
b69ab31286 // don't trigger global click listener in the same tick
b69ab31287 event.stopPropagation();
b69ab31288 }
b69ab31289 }
b69ab31290 : undefined
b69ab31291 }>
b69ab31292 {visible && ref.current && (
b69ab31293 <RenderTooltipOnto
b69ab31294 delayMs={realDelayMs}
b69ab31295 element={ref.current}
b69ab31296 placement={placement}
b69ab31297 interactive={interactive}
b69ab31298 onTooltipMouseEnter={() => {
b69ab31299 isHoveringTooltipContent.current = true;
b69ab31300 }}
b69ab31301 onTooltipMouseLeave={() => {
b69ab31302 isHoveringTooltipContent.current = false;
b69ab31303 if (trigger === 'hover') {
b69ab31304 setVisible(false);
b69ab31305 }
b69ab31306 }}>
b69ab31307 {getContent()}
b69ab31308 </RenderTooltipOnto>
b69ab31309 )}
b69ab31310 {children}
b69ab31311 </div>
b69ab31312 );
b69ab31313}
b69ab31314
b69ab31315/**
b69ab31316 * If you click inside a tooltip triggered by click, we don't want to dismiss the tooltip.
b69ab31317 * We consider any click in a descendant of ANY tooltip as a click.
b69ab31318 * Same applies for scroll events inside tooltips.
b69ab31319 */
b69ab31320function eventIsFromInsideTooltip(event: MouseEvent): boolean {
b69ab31321 const parentTooltip = findParentWithClassName(event.target as HTMLElement, 'tooltip');
b69ab31322 return parentTooltip != null;
b69ab31323}
b69ab31324
b69ab31325function RenderTooltipOnto({
b69ab31326 element,
b69ab31327 placement,
b69ab31328 children,
b69ab31329 delayMs,
b69ab31330 interactive,
b69ab31331 onTooltipMouseEnter,
b69ab31332 onTooltipMouseLeave,
b69ab31333}: {
b69ab31334 element: HTMLElement;
b69ab31335 placement: Placement;
b69ab31336 children: ReactNode;
b69ab31337 delayMs?: number;
b69ab31338 interactive?: boolean;
b69ab31339 onTooltipMouseEnter?: () => void;
b69ab31340 onTooltipMouseLeave?: () => void;
b69ab31341}) {
b69ab31342 const sourceBoundingRect = element.getBoundingClientRect();
b69ab31343 const tooltipRef = useRef<HTMLDivElement | null>(null);
b69ab31344
b69ab31345 const zoom = getZoomLevel();
b69ab31346 let effectivePlacement = placement;
b69ab31347 const viewportDimensions = document.body.getBoundingClientRect();
b69ab31348 viewportDimensions.width /= zoom;
b69ab31349 viewportDimensions.height /= zoom;
b69ab31350
b69ab31351 // to center the tooltip over the tooltip-creator, we need to measure its final rendered size
b69ab31352 const renderedDimensions = useRenderedDimensions(tooltipRef, children);
b69ab31353 const position = offsetsForPlacement(
b69ab31354 placement,
b69ab31355 sourceBoundingRect,
b69ab31356 renderedDimensions,
b69ab31357 viewportDimensions,
b69ab31358 );
b69ab31359 effectivePlacement = position.autoPlacement ?? placement;
b69ab31360 // The tooltip may end up overflowing off the screen, since it's rendered absolutely.
b69ab31361 // We can push it back as needed with an additional offset.
b69ab31362 const viewportAdjust = getViewportAdjustedDelta(effectivePlacement, position, renderedDimensions);
b69ab31363
b69ab31364 const style: React.CSSProperties = {
b69ab31365 animationDelay: delayMs ? `${delayMs}ms` : undefined,
b69ab31366 };
b69ab31367
b69ab31368 const pad = 10;
b69ab31369
b69ab31370 if (position.left > viewportDimensions.width / 2) {
b69ab31371 // All our position computations use top+left.
b69ab31372 // If we position using `left`, but the tooltip is near the right edge,
b69ab31373 // it will squish itself to fit rather than push itself further left.
b69ab31374 // Instead, we need to position with `right`, computed from left. based on the viewport dimension.
b69ab31375 style.right =
b69ab31376 viewportDimensions.width - (position.left + viewportAdjust.left + renderedDimensions.width);
b69ab31377 } else {
b69ab31378 style.left = position.left + viewportAdjust.left;
b69ab31379 }
b69ab31380 // Note: The same could technically apply for top/bottom, but only for left/right positioned tooltips which are less common,
b69ab31381 // so in practice it matters less.
b69ab31382 if (position.top > viewportDimensions.height / 2) {
b69ab31383 style.bottom =
b69ab31384 viewportDimensions.height - (position.top + viewportAdjust.top + renderedDimensions.height);
b69ab31385 style.maxHeight = viewportDimensions.height - style.bottom - 3 * pad;
b69ab31386 } else {
b69ab31387 style.top = position.top + viewportAdjust.top;
b69ab31388 style.maxHeight = viewportDimensions.height - style.top - 3 * pad;
b69ab31389 }
b69ab31390 style.height =
b69ab31391 renderedDimensions.height > style.maxHeight
b69ab31392 ? '100%' // allow scrolling
b69ab31393 : 'unset';
b69ab31394
b69ab31395 // Use a portal so the tooltip element is rendered into the global list of tooltips,
b69ab31396 // rather than as a descendant of the tooltip creator.
b69ab31397 // This allows us to use absolute coordinates for positioning, and for
b69ab31398 // tooltips to "escape" their containing elements, scroll, inherited styles, etc.
b69ab31399 return (
b69ab31400 <ViewportOverlay>
b69ab31401 <div
b69ab31402 ref={tooltipRef}
b69ab31403 role="tooltip"
b69ab31404 className={
b69ab31405 `tooltip tooltip-${effectivePlacement}` +
b69ab31406 (typeof children === 'string' ? ' simple-text-tooltip' : '')
b69ab31407 }
b69ab31408 style={style}
b69ab31409 onMouseEnter={interactive ? onTooltipMouseEnter : undefined}
b69ab31410 onMouseLeave={interactive ? onTooltipMouseLeave : undefined}>
b69ab31411 <div
b69ab31412 className={`tooltip-arrow tooltip-arrow-${effectivePlacement}`}
b69ab31413 // If we had to push the tooltip back to prevent overflow,
b69ab31414 // we also need to move the arrow the opposite direction so it still lines up.
b69ab31415 style={{transform: `translate(${-viewportAdjust.left}px, ${-viewportAdjust.top}px)`}}
b69ab31416 />
b69ab31417 {children}
b69ab31418 </div>
b69ab31419 </ViewportOverlay>
b69ab31420 );
b69ab31421}
b69ab31422
b69ab31423type OffsetPlacement = {
b69ab31424 top: number;
b69ab31425 left: number;
b69ab31426 autoPlacement?: Placement;
b69ab31427};
b69ab31428
b69ab31429/**
b69ab31430 * Offset tooltip from tooltipCreator's absolute position using `placement`,
b69ab31431 * such that it is centered and on the correct side.
b69ab31432 * This requires the rendered tooltip's width and height (for centering).
b69ab31433 * Coordinates are left&top absolute offsets.
b69ab31434 *
b69ab31435 * When appropriate, we also detect if this placement would go offscreen
b69ab31436 * and instead provide a better placement.
b69ab31437 *
b69ab31438 * In this diagram, `0` is `tooltipCreatorRect`,
b69ab31439 * `1` is what we want to compute using 0 and the size of the rendered tooltip.
b69ab31440 *
b69ab31441 * 0---*
b69ab31442 * | | <- tooltip creator (The thing you hover to see the tooltip)
b69ab31443 * *---*
b69ab31444 * ^ <- tooltip arrow
b69ab31445 * 1---------+
b69ab31446 * | | <- tooltip
b69ab31447 * +---------+
b69ab31448 *
b69ab31449 */
b69ab31450function offsetsForPlacement(
b69ab31451 placement: Placement,
b69ab31452 tooltipCreatorRect: DOMRect,
b69ab31453 tooltipDimensions: {width: number; height: number},
b69ab31454 viewportDimensions: DOMRect,
b69ab31455): OffsetPlacement {
b69ab31456 const padding = 5;
b69ab31457 let result: OffsetPlacement = {top: 0, left: 0};
b69ab31458 let currentPlacement = placement;
b69ab31459 for (let i = 0; i <= 2; i++) {
b69ab31460 switch (currentPlacement) {
b69ab31461 case 'top': {
b69ab31462 result = {
b69ab31463 top: tooltipCreatorRect.top - padding - tooltipDimensions.height,
b69ab31464 left:
b69ab31465 tooltipCreatorRect.left + tooltipCreatorRect.width / 2 - tooltipDimensions.width / 2,
b69ab31466 };
b69ab31467 if (result.top < 0) {
b69ab31468 currentPlacement = 'bottom';
b69ab31469 continue;
b69ab31470 }
b69ab31471 break;
b69ab31472 }
b69ab31473 case 'bottom': {
b69ab31474 result = {
b69ab31475 top: tooltipCreatorRect.top + tooltipCreatorRect.height + padding,
b69ab31476 left:
b69ab31477 tooltipCreatorRect.left + tooltipCreatorRect.width / 2 - tooltipDimensions.width / 2,
b69ab31478 };
b69ab31479 if (result.top + tooltipDimensions.height > viewportDimensions.height) {
b69ab31480 currentPlacement = 'top';
b69ab31481 continue;
b69ab31482 }
b69ab31483 break;
b69ab31484 }
b69ab31485 case 'left': {
b69ab31486 result = {
b69ab31487 top:
b69ab31488 tooltipCreatorRect.top + tooltipCreatorRect.height / 2 - tooltipDimensions.height / 2,
b69ab31489 left: tooltipCreatorRect.left - tooltipDimensions.width - padding,
b69ab31490 };
b69ab31491 if (result.left < 0) {
b69ab31492 currentPlacement = 'right';
b69ab31493 continue;
b69ab31494 }
b69ab31495 break;
b69ab31496 }
b69ab31497 case 'right': {
b69ab31498 result = {
b69ab31499 top:
b69ab31500 tooltipCreatorRect.top + tooltipCreatorRect.height / 2 - tooltipDimensions.height / 2,
b69ab31501 left: tooltipCreatorRect.right + padding,
b69ab31502 };
b69ab31503 if (result.left + tooltipDimensions.width > viewportDimensions.width) {
b69ab31504 currentPlacement = 'left';
b69ab31505 continue;
b69ab31506 }
b69ab31507 break;
b69ab31508 }
b69ab31509 }
b69ab31510 break;
b69ab31511 }
b69ab31512 // Set autoPlacement if we chose a different placement.
b69ab31513 if (currentPlacement !== placement) {
b69ab31514 result.autoPlacement = currentPlacement;
b69ab31515 }
b69ab31516 return result;
b69ab31517}
b69ab31518
b69ab31519/**
b69ab31520 * If the rendered tooltip would overflow outside the screen bounds,
b69ab31521 * we need to translate the tooltip back into bounds.
b69ab31522 */
b69ab31523function getViewportAdjustedDelta(
b69ab31524 placement: Placement,
b69ab31525 pos: {top: number; left: number},
b69ab31526 renderedDimensions: {width: number; height: number},
b69ab31527): {left: number; top: number} {
b69ab31528 const delta = {top: 0, left: 0};
b69ab31529
b69ab31530 const viewportPadding = 5;
b69ab31531 const viewportDimensions = document.body.getBoundingClientRect();
b69ab31532
b69ab31533 const zoom = getZoomLevel();
b69ab31534 viewportDimensions.width /= zoom;
b69ab31535 viewportDimensions.height /= zoom;
b69ab31536
b69ab31537 if (placement === 'right' || placement === 'left') {
b69ab31538 const topEdgeOffset = pos.top - viewportPadding;
b69ab31539 const bottomEdgeOffset = pos.top + viewportPadding + renderedDimensions.height;
b69ab31540 if (topEdgeOffset < viewportDimensions.top) {
b69ab31541 // top overflow
b69ab31542 delta.top = viewportDimensions.top - topEdgeOffset;
b69ab31543 } else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) {
b69ab31544 // bottom overflow
b69ab31545 delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset;
b69ab31546 }
b69ab31547 } else {
b69ab31548 const leftEdgeOffset = pos.left - viewportPadding;
b69ab31549 const rightEdgeOffset = pos.left + viewportPadding + renderedDimensions.width;
b69ab31550 if (leftEdgeOffset < viewportDimensions.left) {
b69ab31551 // left overflow
b69ab31552 delta.left = viewportDimensions.left - leftEdgeOffset;
b69ab31553 } else if (rightEdgeOffset > viewportDimensions.right) {
b69ab31554 // right overflow
b69ab31555 delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset;
b69ab31556 }
b69ab31557 }
b69ab31558
b69ab31559 return delta;
b69ab31560}
b69ab31561
b69ab31562function useRenderedDimensions(ref: React.MutableRefObject<HTMLDivElement | null>, deps: unknown) {
b69ab31563 const [dimensions, setDimensions] = useState({width: 0, height: 0});
b69ab31564
b69ab31565 useLayoutEffect(() => {
b69ab31566 const target = ref.current;
b69ab31567 if (target == null) {
b69ab31568 return;
b69ab31569 }
b69ab31570
b69ab31571 const updateDimensions = () => {
b69ab31572 setDimensions({
b69ab31573 width: target.offsetWidth,
b69ab31574 height: target.offsetHeight,
b69ab31575 });
b69ab31576 };
b69ab31577
b69ab31578 updateDimensions();
b69ab31579
b69ab31580 const observer = new ResizeObserver(entries => {
b69ab31581 entries.forEach(entry => {
b69ab31582 if (entry.target === target) {
b69ab31583 updateDimensions();
b69ab31584 }
b69ab31585 });
b69ab31586 });
b69ab31587
b69ab31588 // Children might resize without re-rendering the tooltip.
b69ab31589 // Observe that and trigger re-positioning.
b69ab31590 // Unlike useLayoutEffect, the ResizeObserver does not prevent
b69ab31591 // rendering the old state.
b69ab31592 observer.observe(target);
b69ab31593 return () => observer.disconnect();
b69ab31594 }, [ref, deps]);
b69ab31595
b69ab31596 return dimensions;
b69ab31597}