19.8 KB598 lines
Blame
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
8import type {MouseEvent, ReactNode} from 'react';
9import type {ExclusiveOr} from './Types';
10
11import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
12import {ViewportOverlay} from './ViewportOverlay';
13import {findParentWithClassName} from './utils';
14import {getZoomLevel} from './zoom';
15
16import './Tooltip.css';
17
18export type Placement = 'top' | 'bottom' | 'left' | 'right';
19
20/**
21 * Default delay used for hover tooltips to convey documentation information.
22 */
23export const DOCUMENTATION_DELAY = 750;
24
25const tooltipGroups: Map<string, EventTarget> = new Map();
26function 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 */
38export 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
84type TooltipPropsWithChildren = {children: ReactNode} & TooltipProps;
85
86type VisibleState =
87 | true /* primary content (prefers component) is visible */
88 | false
89 | 'title' /* 'title', not 'component' is visible */;
90
91class 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 */
123export 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 */
320function eventIsFromInsideTooltip(event: MouseEvent): boolean {
321 const parentTooltip = findParentWithClassName(event.target as HTMLElement, 'tooltip');
322 return parentTooltip != null;
323}
324
325function 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
423type 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 */
450function 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 */
523function 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
562function 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