// SPDX-License-Identifier: AGPL-3.0-or-later import Accessibility from '@app/features/accessibility/state/Accessibility'; import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs'; import {Logger} from '@app/features/platform/utils/AppLogger'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import * as ToastCommands from '@app/features/ui/commands/ToastCommands'; import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext'; import ContextMenuState from '@app/features/ui/state/ContextMenu'; import KeyboardMode from '@app/features/ui/state/KeyboardMode'; import MobileLayout from '@app/features/ui/state/MobileLayout'; import styles from '@app/features/ui/tooltip/Tooltip.module.css'; import {useExclusiveTooltip} from '@app/features/ui/tooltip/TooltipExclusivity'; import {isTooltipHandoffWarm, markTooltipOpen} from '@app/features/ui/tooltip/TooltipHandoff'; import { getTooltipScrollSuppressRemainingMs, subscribeTooltipScrollHide, } from '@app/features/ui/tooltip/TooltipScrollCoordinator'; import { createTooltipSnapshot, getTooltipStateValue, selectTooltipModel, type TooltipEnvironment, type TooltipMachineEvent, tooltipSnapshotsAreEquivalent, transitionTooltipSnapshot, } from '@app/features/ui/tooltip/TooltipStateMachine'; import {appZoomCssPx, appZoomLayoutPx} from '@app/features/ui/utils/AppZoomUtils'; import {getReducedMotionProps, TOOLTIP_MOTION} from '@app/features/ui/utils/ReducedMotionAnimation'; import { canUseWindowFocusedHoverControls, subscribeWindowHoverControlsChange, } from '@app/features/ui/utils/WindowFocusInteractionGuard'; import {elementSupportsRef} from '@app/lib/react'; import type {ExtendedDocument} from '@app/types/browser.d'; import {FloatingPortal} from '@floating-ui/react'; import {arrow, autoUpdate, computePosition, flip, offset, shift} from '@floating-ui/react-dom'; import {clsx} from 'clsx'; import {AnimatePresence, motion} from 'framer-motion'; import {observer} from 'mobx-react-lite'; import React, {useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from 'react'; const logger = new Logger('Tooltip'); export type TooltipPosition = 'top' | 'left' | 'right' | 'bottom'; export type TooltipType = 'normal' | 'error'; const TOOLTIP_AUTO_UPDATE_OPTIONS = { ancestorScroll: false, ancestorResize: true, elementResize: true, layoutShift: true, } as const; export const getFullscreenOverflowBoundary = (target: HTMLElement): HTMLElement | null => { const ownerDocument = target.ownerDocument as ExtendedDocument; const ownerWindow = ownerDocument.defaultView; if (!ownerWindow) return null; const fullscreenElement = ownerDocument.fullscreenElement ?? ownerDocument.webkitFullscreenElement ?? ownerDocument.mozFullScreenElement ?? ownerDocument.msFullscreenElement ?? null; if (!(fullscreenElement instanceof ownerWindow.HTMLElement)) return null; return fullscreenElement.contains(target) ? fullscreenElement : null; }; const tooltipPortalRoots = new WeakMap(); const getTooltipPortalRoot = (ownerDocument: Document): HTMLElement => { const existingRoot = tooltipPortalRoots.get(ownerDocument); if (existingRoot) { return existingRoot; } const root = ownerDocument.createElement('div'); root.className = styles.tooltips; root.setAttribute('data-tooltip-portal-root', 'true'); ownerDocument.body.appendChild(root); tooltipPortalRoots.set(ownerDocument, root); return root; }; export const useTooltipPortalRoot = (enabled = true, targetDocument?: Document): HTMLElement | undefined => { const [root, setRoot] = useState(undefined); const portalHost = usePortalHost(); const resolvedDocument = targetDocument ?? document; const scopedPortalHost = portalHost && portalHost.ownerDocument === resolvedDocument ? portalHost : null; useLayoutEffect(() => { if (!enabled) return; if (scopedPortalHost) { setRoot(undefined); return; } setRoot(getTooltipPortalRoot(resolvedDocument)); }, [enabled, resolvedDocument, scopedPortalHost]); if (!enabled) return undefined; return scopedPortalHost ?? root; }; const MAX_WIDTH_MAP = { default: remFromPx(190), xl: remFromPx(350), none: 'none' as const, }; const TooltipPositionToStyle: Record = { top: styles.tooltipTop, left: styles.tooltipLeft, right: styles.tooltipRight, bottom: styles.tooltipBottom, }; const TooltipTypeToStyle: Record = { normal: styles.tooltipPrimary, error: styles.tooltipRed, }; interface TooltipProps { text: string | (() => React.ReactNode); type?: TooltipType; position?: TooltipPosition; align?: 'center' | 'top' | 'bottom' | 'left' | 'right'; nudge?: number; delay?: number; padding?: number; maxWidth?: 'default' | 'xl' | 'none'; size?: 'default' | 'large'; openOnMountHover?: boolean; allowWhenContextMenuOpen?: boolean; children?: React.ReactElement; } type TooltipVisibilityDriver = 'hover' | 'focus'; const nodeHasAccessibleText = (node: React.ReactNode): boolean => { if (typeof node === 'string') return node.trim().length > 0; if (typeof node === 'number') return true; if (Array.isArray(node)) return node.some(nodeHasAccessibleText); if (!React.isValidElement(node)) return false; const props = node.props as { children?: React.ReactNode; 'aria-hidden'?: boolean | 'true' | 'false'; }; if (props['aria-hidden'] === true || props['aria-hidden'] === 'true') return false; return nodeHasAccessibleText(props.children); }; const tooltipHasRenderableContent = (text: TooltipProps['text']): boolean => { if (typeof text === 'function') return true; if (typeof text === 'string') return text.trim().length > 0; return !!text; }; const TooltipContainer = React.forwardRef< HTMLDivElement, { text: string | (() => React.ReactNode); type: TooltipType; position: TooltipPosition; maxWidth: 'default' | 'xl' | 'none'; size: 'default' | 'large'; arrowRef: React.RefObject | React.RefObject; arrowX?: number; arrowY?: number; } >(({text, type, position, maxWidth, size, arrowRef, arrowX, arrowY}, ref) => { const content = typeof text === 'function' ? text() : text; if ( !content || (Array.isArray(content) && content.length === 0) || (typeof content === 'string' && content.trim().length === 0) ) { return null; } const arrowStyle: React.CSSProperties = {}; if (position === 'top' || position === 'bottom') { if (arrowX != null) { arrowStyle.left = appZoomCssPx(arrowX); arrowStyle.marginLeft = 0; } } else { if (arrowY != null) { arrowStyle.top = appZoomCssPx(arrowY); arrowStyle.marginTop = 0; } } return (
{content}
); }); TooltipContainer.displayName = 'TooltipContainer'; export const Tooltip = observer( ({ text, type = 'normal', position = 'top', nudge = 0, delay = 0, padding = 8, maxWidth = 'default', size = 'default', openOnMountHover = true, allowWhenContextMenuOpen = false, children, }: TooltipProps) => { const tooltipId = useId(); const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled; const mobileLayout = MobileLayout; type TooltipChildProps = React.HTMLAttributes & {disabled?: boolean; ref?: React.Ref}; const child = children && React.isValidElement(children) ? React.Children.only(children) : null; const childSupportsRef = child ? elementSupportsRef(child) : false; const childRef = childSupportsRef && child ? (child.props.ref ?? null) : null; const childProps = (child?.props ?? {}) as TooltipChildProps; const { onMouseEnter: childMouseEnter, onMouseLeave: childMouseLeave, onClick: childClick, onTouchStart: childTouchStart, onTouchEnd: childTouchEnd, onTouchCancel: childTouchCancel, onMouseDownCapture: childMouseDownCapture, onPointerDownCapture: childPointerDownCapture, onFocus: childFocus, onBlur: childBlur, onKeyDown: childKeyDown, } = childProps; const isDisabled = Boolean(childProps.disabled || childProps['aria-disabled']); const hasRenderableContent = tooltipHasRenderableContent(text); const targetRef = useRef(null); const getTooltipOwnerDocument = useCallback((): Document => targetRef.current?.ownerDocument ?? document, []); const getTooltipOwnerWindow = useCallback((): Window => { return getTooltipOwnerDocument().defaultView ?? window; }, [getTooltipOwnerDocument]); const canUseTooltipHoverControls = useCallback((): boolean => { return canUseWindowFocusedHoverControls(getTooltipOwnerDocument().documentElement); }, [getTooltipOwnerDocument]); const isTooltipContextMenuOpen = useCallback((): boolean => { if (allowWhenContextMenuOpen) return false; return ContextMenuState.getContextMenu(getTooltipOwnerDocument()) != null; }, [allowWhenContextMenuOpen, getTooltipOwnerDocument]); const contextMenuOpen = isTooltipContextMenuOpen(); const tooltipEnvironment = useMemo( (): TooltipEnvironment => ({ disabled: isDisabled, mobileEnabled: mobileLayout.enabled, contextMenuOpen, keyboardModeEnabled, hoverControlsEnabled: canUseTooltipHoverControls(), hasRenderableContent, }), [ canUseTooltipHoverControls, contextMenuOpen, hasRenderableContent, isDisabled, keyboardModeEnabled, mobileLayout.enabled, ], ); const [tooltipSnapshot, setTooltipSnapshot] = useState(() => createTooltipSnapshot(tooltipEnvironment)); const tooltipModel = selectTooltipModel(tooltipSnapshot); const tooltipStateValue = getTooltipStateValue(tooltipSnapshot); const shouldRenderTooltip = tooltipModel.shouldRender; const hasActiveVisibilityState = tooltipStateValue !== 'hidden' || tooltipModel.hasVisibilityDriver; const portalRoot = useTooltipPortalRoot(shouldRenderTooltip, getTooltipOwnerDocument()); const timerRef = useRef(null); const timerWindowRef = useRef(null); const pendingDelayMsRef = useRef(0); const longPressTimeoutRef = useRef(null); const pointerFocusTimeoutRef = useRef(null); const pointerFocusWindowRef = useRef(null); const ignoreNextFocusRef = useRef(false); const arrowRef = useRef(null); const tooltipRef = useRef(null); const cleanupRef = useRef<(() => void) | null>(null); const rafRef = useRef(null); const rafWindowRef = useRef(null); const isCalculatingRef = useRef(false); const mountedRef = useRef(true); const positionRequestIdRef = useRef(0); const shouldRenderTooltipRef = useRef(shouldRenderTooltip); const tooltipSnapshotRef = useRef(tooltipSnapshot); const tooltipModelRef = useRef(tooltipModel); const environmentRef = useRef(tooltipEnvironment); shouldRenderTooltipRef.current = shouldRenderTooltip; tooltipSnapshotRef.current = tooltipSnapshot; tooltipModelRef.current = tooltipModel; environmentRef.current = tooltipEnvironment; const isCrossDocumentTooltip = portalRoot != null && portalRoot.ownerDocument !== document; const tooltipMotion = getReducedMotionProps( TOOLTIP_MOTION, Accessibility.useReducedMotion || isCrossDocumentTooltip, ); const [tooltipState, setTooltipState] = useState({ x: 0, y: 0, arrowX: 0, arrowY: 0, isReady: false, actualPlacement: position, }); const sendTooltipEvent = useCallback((event: TooltipMachineEvent) => { if (!mountedRef.current) return; const prev = tooltipSnapshotRef.current; const next = transitionTooltipSnapshot(prev, event); if (tooltipSnapshotsAreEquivalent(prev, next)) return; tooltipSnapshotRef.current = next; setTooltipSnapshot(next); }, []); const refreshTooltipEnvironment = useCallback(() => { const environment = { ...environmentRef.current, contextMenuOpen: isTooltipContextMenuOpen(), hoverControlsEnabled: canUseTooltipHoverControls(), }; environmentRef.current = environment; sendTooltipEvent({type: 'tooltip.environmentChanged', environment}); }, [canUseTooltipHoverControls, isTooltipContextMenuOpen, sendTooltipEvent]); const getTextString = useCallback((): string => { return typeof text === 'string' ? text : ''; }, [text]); const clearDelayTimer = useCallback(() => { if (timerRef.current != null) { (timerWindowRef.current ?? window).clearTimeout(timerRef.current); timerRef.current = null; timerWindowRef.current = null; } }, []); const clearPointerFocusTimeout = useCallback(() => { if (pointerFocusTimeoutRef.current != null) { (pointerFocusWindowRef.current ?? window).clearTimeout(pointerFocusTimeoutRef.current); pointerFocusTimeoutRef.current = null; pointerFocusWindowRef.current = null; } }, []); const markPointerFocus = useCallback(() => { ignoreNextFocusRef.current = true; clearPointerFocusTimeout(); const ownerWindow = getTooltipOwnerWindow(); pointerFocusWindowRef.current = ownerWindow; pointerFocusTimeoutRef.current = ownerWindow.setTimeout(() => { pointerFocusTimeoutRef.current = null; pointerFocusWindowRef.current = null; ignoreNextFocusRef.current = false; }, 0); }, [clearPointerFocusTimeout, getTooltipOwnerWindow]); const cancelRaf = useCallback(() => { if (rafRef.current != null) { (rafWindowRef.current ?? window).cancelAnimationFrame(rafRef.current); rafRef.current = null; rafWindowRef.current = null; } }, []); const resetTooltipPosition = useCallback(() => { positionRequestIdRef.current += 1; setTooltipState((prev) => prev.isReady || prev.actualPlacement !== position ? {...prev, isReady: false, actualPlacement: position} : prev, ); }, [position]); const dismissTooltip = useCallback(() => { clearDelayTimer(); cancelRaf(); const model = tooltipModelRef.current; if (model.state === 'hidden' && !model.hasVisibilityDriver && !model.delayPending) return; sendTooltipEvent({type: 'tooltip.dismiss'}); resetTooltipPosition(); }, [cancelRaf, clearDelayTimer, resetTooltipPosition, sendTooltipEvent]); useExclusiveTooltip(shouldRenderTooltip, dismissTooltip); const updatePositionNow = useCallback(async () => { if ( !shouldRenderTooltipRef.current || !targetRef.current || !tooltipRef.current || isCalculatingRef.current || !hasRenderableContent ) { return; } const requestId = ++positionRequestIdRef.current; isCalculatingRef.current = true; try { const target = targetRef.current; const tooltip = tooltipRef.current; Object.assign(tooltip.style, { position: 'fixed', left: '-9999px', top: '-9999px', } as CSSStyleDeclaration); const overflowBoundary = getFullscreenOverflowBoundary(target); const boundaryOptions = overflowBoundary ? {boundary: overflowBoundary} : undefined; const middleware = [ offset(padding + nudge), flip(boundaryOptions), shift({padding: 8, ...boundaryOptions}), ...(arrowRef.current ? [arrow({element: arrowRef.current})] : []), ]; const {x, y, placement, middlewareData} = await computePosition(target, tooltip, { placement: position, strategy: 'fixed', middleware, }); if ( !mountedRef.current || !shouldRenderTooltipRef.current || positionRequestIdRef.current !== requestId || targetRef.current !== target || tooltipRef.current !== tooltip ) { return; } setTooltipState((prev) => { const next = { x, y, arrowX: middlewareData.arrow?.x ?? 0, arrowY: middlewareData.arrow?.y ?? 0, isReady: true, actualPlacement: placement.split('-')[0] as TooltipPosition, }; return prev.x !== next.x || prev.y !== next.y || prev.arrowX !== next.arrowX || prev.arrowY !== next.arrowY || prev.isReady !== next.isReady || prev.actualPlacement !== next.actualPlacement ? next : prev; }); Object.assign(tooltip.style, {left: appZoomCssPx(x), top: appZoomCssPx(y)} as CSSStyleDeclaration); } catch (error) { logger.error('Error positioning tooltip:', error); if (tooltipRef.current) { tooltipRef.current.style.visibility = 'visible'; } } finally { isCalculatingRef.current = false; } }, [hasRenderableContent, nudge, padding, position]); const updatePosition = useCallback(() => { cancelRaf(); const ownerWindow = getTooltipOwnerWindow(); rafWindowRef.current = ownerWindow; rafRef.current = ownerWindow.requestAnimationFrame(() => { rafRef.current = null; rafWindowRef.current = null; void updatePositionNow(); }); }, [updatePositionNow, cancelRaf, getTooltipOwnerWindow]); const getVisibilityDriverDelayMs = useCallback(() => { const scrollSuppressRemainingMs = getTooltipScrollSuppressRemainingMs(); const openDelay = isTooltipHandoffWarm() ? 0 : delay; switch (true) { case openDelay > 0 || scrollSuppressRemainingMs > 0: return Math.max(openDelay, scrollSuppressRemainingMs > 0 ? scrollSuppressRemainingMs + 1 : 0); default: return 0; } }, [delay]); const beginVisibilityDriver = useCallback( (driver: TooltipVisibilityDriver) => { refreshTooltipEnvironment(); const pendingDelayMs = getVisibilityDriverDelayMs(); pendingDelayMsRef.current = pendingDelayMs; switch (driver) { case 'hover': sendTooltipEvent({ type: 'tooltip.hoverChanged', hovered: true, delay: pendingDelayMs > 0, }); return; case 'focus': sendTooltipEvent({ type: 'tooltip.focusChanged', focused: true, delay: pendingDelayMs > 0, }); return; } }, [getVisibilityDriverDelayMs, refreshTooltipEnvironment, sendTooltipEvent], ); const endVisibilityDriver = useCallback( (driver: TooltipVisibilityDriver) => { switch (driver) { case 'hover': sendTooltipEvent({type: 'tooltip.hoverChanged', hovered: false, delay: false}); return; case 'focus': sendTooltipEvent({type: 'tooltip.focusChanged', focused: false, delay: false}); return; } }, [sendTooltipEvent], ); const showMobileToast = useCallback(() => { const textContent = getTextString(); if (textContent) { ToastCommands.createToast({ type: 'info', children: textContent, timeout: 3000, }); } }, [getTextString]); const handleLongPressStart = useCallback(() => { if (!mobileLayout.enabled || !getTextString()) return; if (longPressTimeoutRef.current != null) { window.clearTimeout(longPressTimeoutRef.current); } longPressTimeoutRef.current = window.setTimeout(() => { showMobileToast(); }, 500); }, [mobileLayout.enabled, showMobileToast, getTextString]); const handleLongPressEnd = useCallback(() => { if (longPressTimeoutRef.current != null) { window.clearTimeout(longPressTimeoutRef.current); longPressTimeoutRef.current = null; } }, []); useEffect(() => { sendTooltipEvent({type: 'tooltip.environmentChanged', environment: tooltipEnvironment}); }, [sendTooltipEvent, tooltipEnvironment]); useEffect(() => { clearDelayTimer(); if (tooltipStateValue !== 'delayed') return; const ownerWindow = getTooltipOwnerWindow(); timerWindowRef.current = ownerWindow; timerRef.current = ownerWindow.setTimeout(() => { timerRef.current = null; timerWindowRef.current = null; pendingDelayMsRef.current = 0; sendTooltipEvent({type: 'tooltip.delayElapsed'}); }, pendingDelayMsRef.current); return clearDelayTimer; }, [clearDelayTimer, getTooltipOwnerWindow, sendTooltipEvent, tooltipStateValue]); useLayoutEffect(() => { if (!shouldRenderTooltip || !targetRef.current || !tooltipRef.current) { if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; } return; } updatePosition(); if (targetRef.current.ownerDocument.contains(targetRef.current)) { cleanupRef.current = autoUpdate( targetRef.current, tooltipRef.current, updatePosition, TOOLTIP_AUTO_UPDATE_OPTIONS, ); } return () => { if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; } }; }, [shouldRenderTooltip, updatePosition]); useEffect(() => { if (shouldRenderTooltip) return; cancelRaf(); resetTooltipPosition(); if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; } }, [cancelRaf, resetTooltipPosition, shouldRenderTooltip]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; positionRequestIdRef.current += 1; clearDelayTimer(); if (longPressTimeoutRef.current != null) { window.clearTimeout(longPressTimeoutRef.current); longPressTimeoutRef.current = null; } clearPointerFocusTimeout(); if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; } cancelRaf(); }; }, [cancelRaf, clearDelayTimer, clearPointerFocusTimeout]); useEffect(() => { if (!shouldRenderTooltip) return; return markTooltipOpen(); }, [shouldRenderTooltip]); useEffect(() => { if (!shouldRenderTooltip) return; return subscribeTooltipScrollHide(dismissTooltip); }, [dismissTooltip, shouldRenderTooltip]); useEffect(() => { if (!hasActiveVisibilityState) return; const closeIfHoverControlsDisabled = () => { refreshTooltipEnvironment(); if (!canUseTooltipHoverControls()) { dismissTooltip(); } }; const ownerWindow = getTooltipOwnerWindow(); const unsubscribe = subscribeWindowHoverControlsChange(closeIfHoverControlsDisabled, ownerWindow); ownerWindow.addEventListener('blur', dismissTooltip); return () => { unsubscribe(); ownerWindow.removeEventListener('blur', dismissTooltip); }; }, [ canUseTooltipHoverControls, dismissTooltip, getTooltipOwnerWindow, hasActiveVisibilityState, refreshTooltipEnvironment, ]); const mergedRef = useMergeRefs([targetRef, childSupportsRef ? childRef : null]); const wrapperRef = useMergeRefs([targetRef]); const hasChild = child != null; useEffect(() => { if (!openOnMountHover || !hasChild || mobileLayout.enabled || isDisabled) return; const target = targetRef.current; if (!target?.matches(':hover')) return; beginVisibilityDriver('hover'); }, [beginVisibilityDriver, hasChild, isDisabled, mobileLayout.enabled, openOnMountHover]); useEffect(() => { if (!hasChild || mobileLayout.enabled) return; const target = targetRef.current; if (!target) return; if (target.ownerDocument === document) return; const handleNativePointerEnter = () => { if (isDisabled) { endVisibilityDriver('hover'); return; } beginVisibilityDriver('hover'); }; const handleNativePointerLeave = () => { endVisibilityDriver('hover'); }; const handleNativeClick = () => { dismissTooltip(); }; target.addEventListener('pointerenter', handleNativePointerEnter); target.addEventListener('pointerleave', handleNativePointerLeave); target.addEventListener('click', handleNativeClick); return () => { target.removeEventListener('pointerenter', handleNativePointerEnter); target.removeEventListener('pointerleave', handleNativePointerLeave); target.removeEventListener('click', handleNativeClick); }; }, [beginVisibilityDriver, dismissTooltip, endVisibilityDriver, hasChild, isDisabled, mobileLayout.enabled]); useEffect(() => { if (!tooltipModel.hovered || tooltipModel.dismissed || mobileLayout.enabled) return; const ownerWindow = getTooltipOwnerWindow(); let frameId: number | null = null; const verifyTargetHover = () => { const target = targetRef.current; if (!target?.matches(':hover')) { endVisibilityDriver('hover'); frameId = null; return; } frameId = ownerWindow.requestAnimationFrame(verifyTargetHover); }; frameId = ownerWindow.requestAnimationFrame(verifyTargetHover); return () => { if (frameId != null) { ownerWindow.cancelAnimationFrame(frameId); } }; }, [ endVisibilityDriver, getTooltipOwnerWindow, mobileLayout.enabled, tooltipModel.dismissed, tooltipModel.hovered, ]); if (!child) return null; const describedBy = shouldRenderTooltip ? [childProps['aria-describedby'], tooltipId].filter(Boolean).join(' ') : childProps['aria-describedby']; const shouldUseTooltipAsName = typeof text === 'string' && !childProps['aria-label'] && !childProps['aria-labelledby'] && !nodeHasAccessibleText(childProps.children); const tooltipNameProps = shouldUseTooltipAsName ? {'aria-label': text} : {}; const mobileEventHandlers = mobileLayout.enabled ? { onTouchStart: (event: React.TouchEvent) => { handleLongPressStart(); childTouchStart?.(event); }, onTouchEnd: (event: React.TouchEvent) => { handleLongPressEnd(); childTouchEnd?.(event); }, onTouchCancel: (event: React.TouchEvent) => { handleLongPressEnd(); childTouchCancel?.(event); }, onClick: (event: React.MouseEvent) => { childClick?.(event); }, } : {}; const desktopEventHandlers = !mobileLayout.enabled ? { onMouseEnter: (event: React.MouseEvent) => { if (isDisabled) { endVisibilityDriver('hover'); childMouseEnter?.(event); return; } beginVisibilityDriver('hover'); childMouseEnter?.(event); }, onMouseLeave: (event: React.MouseEvent) => { endVisibilityDriver('hover'); childMouseLeave?.(event); }, onClick: (event: React.MouseEvent) => { dismissTooltip(); childClick?.(event); }, onPointerDownCapture: (event: React.PointerEvent) => { markPointerFocus(); childPointerDownCapture?.(event); }, onMouseDownCapture: (event: React.MouseEvent) => { markPointerFocus(); childMouseDownCapture?.(event); }, onFocus: (event: React.FocusEvent) => { const shouldIgnoreFocus = ignoreNextFocusRef.current; ignoreNextFocusRef.current = false; if (shouldIgnoreFocus) { clearPointerFocusTimeout(); endVisibilityDriver('focus'); } else { beginVisibilityDriver('focus'); } if (isDisabled || shouldIgnoreFocus) { dismissTooltip(); } childFocus?.(event); }, onBlur: (event: React.FocusEvent) => { endVisibilityDriver('focus'); childBlur?.(event); }, onKeyDown: (event: React.KeyboardEvent) => { if (event.key === 'Escape' && shouldRenderTooltip) { dismissTooltip(); } childKeyDown?.(event); }, } : {}; const childWithHandlers = React.cloneElement(child, { ...(childSupportsRef ? {ref: mergedRef} : {}), 'aria-describedby': describedBy || undefined, ...tooltipNameProps, ...desktopEventHandlers, ...mobileEventHandlers, }); const triggerNode = childSupportsRef ? ( childWithHandlers ) : ( {childWithHandlers} ); return ( <> {triggerNode} {shouldRenderTooltip && ( { tooltipRef.current = node; if (node && targetRef.current) { updatePosition(); } }} style={{ position: 'fixed', left: appZoomLayoutPx(tooltipState.x), top: appZoomLayoutPx(tooltipState.y), zIndex: 'var(--z-index-tooltip)', visibility: tooltipState.isReady ? 'visible' : 'hidden', }} data-flx="ui.tooltip.tooltip.tooltip" {...tooltipMotion} > )} ); }, );