// // Copyright 2025 DXOS.org // // This is based upon `@radix-ui/react-tooltip` fetched 17 March 2025 at https://github.com/radix-ui/primitives at commit 6e75e11. import { composeEventHandlers } from '@radix-ui/primitive'; import { useComposedRefs } from '@radix-ui/react-compose-refs'; import { createContextScope } from '@radix-ui/react-context'; import type { Scope } from '@radix-ui/react-context'; import { DismissableLayer } from '@radix-ui/react-dismissable-layer'; import { useId } from '@radix-ui/react-id'; import * as PopperPrimitive from '@radix-ui/react-popper'; import { createPopperScope, type PopperAnchorProps } from '@radix-ui/react-popper'; import { Portal as PortalPrimitive } from '@radix-ui/react-portal'; import { Presence } from '@radix-ui/react-presence'; import { Primitive } from '@radix-ui/react-primitive'; import { Slottable } from '@radix-ui/react-slot'; import { type TooltipProps } from '@radix-ui/react-tooltip'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import * as VisuallyHiddenPrimitive from '@radix-ui/react-visually-hidden'; import React, { type ComponentPropsWithoutRef, type ElementRef, type FC, type SyntheticEvent, forwardRef, type MutableRefObject, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useElevationContext, useThemeContext } from '../../hooks'; type TooltipScopedProps

= P & { __scopeTooltip?: Scope }; const [createTooltipContext, createTooltipScope] = createContextScope('Tooltip', [createPopperScope]); const usePopperScope = createPopperScope(); /* ------------------------------------------------------------------------------------------------- * Tooltip * ----------------------------------------------------------------------------------------------- */ const DEFAULT_DELAY_DURATION = 700; const TOOLTIP_OPEN = 'tooltip.open'; const TOOLTIP_NAME = 'Tooltip'; type TooltipContextValue = { contentId: string; open: boolean; stateAttribute: 'closed' | 'delayed-open' | 'instant-open'; trigger: TooltipTriggerElement | null; onTriggerChange(trigger: TooltipTriggerElement | null): void; onTriggerEnter(): void; onTriggerLeave(): void; onOpen(): void; onClose(): void; onPointerInTransitChange(inTransit: boolean): void; isPointerInTransitRef: MutableRefObject; disableHoverableContent: boolean; }; const [TooltipContextProvider, useTooltipContext] = createTooltipContext(TOOLTIP_NAME); interface TooltipProviderProps { children?: ReactNode; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** * The duration from when the pointer enters the trigger until the tooltip gets opened. This will * override the prop with the same name passed to Provider. * @defaultValue 700 */ delayDuration?: number; /** * When `true`, trying to hover the content will result in the tooltip closing as the pointer leaves the trigger. * @defaultValue false */ disableHoverableContent?: boolean; /** * How much time a user has to enter another trigger without incurring a delay again. * @defaultValue 300 */ skipDelayDuration?: number; } const TooltipProvider: FC = (props: TooltipScopedProps) => { const { __scopeTooltip, children, open: openProp, defaultOpen = false, onOpenChange, disableHoverableContent = false, delayDuration = DEFAULT_DELAY_DURATION, skipDelayDuration = 300, } = props; const isOpenDelayedRef = useRef(true); const isPointerInTransitRef = useRef(false); const skipDelayTimerRef = useRef(0); useEffect(() => { const skipDelayTimer = skipDelayTimerRef.current; return () => window.clearTimeout(skipDelayTimer); }, []); const popperScope = usePopperScope(__scopeTooltip); const [trigger, setTrigger] = useState(null); const [content, setContent] = useState(''); const [side, setSide] = useState(undefined); const triggerRef = useRef(trigger); const handleTriggerChange = useCallback((nextTrigger: HTMLButtonElement | null) => { setTrigger(nextTrigger); triggerRef.current = nextTrigger; setContent(nextTrigger?.getAttribute('data-tooltip-content') ?? ''); setSide((nextTrigger?.getAttribute('data-tooltip-side') as TooltipSide | null) ?? undefined); }, []); const contentId = useId(); const openTimerRef = useRef(0); const wasOpenDelayedRef = useRef(false); const handleOpenChange = useCallback( (open: boolean) => { if (open) { window.clearTimeout(skipDelayTimerRef.current); isOpenDelayedRef.current = false; // as `onChange` is called within a lifecycle method we // avoid dispatching via `dispatchDiscreteCustomEvent`. document.dispatchEvent(new CustomEvent(TOOLTIP_OPEN)); } else { window.clearTimeout(skipDelayTimerRef.current); skipDelayTimerRef.current = window.setTimeout(() => (isOpenDelayedRef.current = true), skipDelayDuration); } onOpenChange?.(open); }, [skipDelayDuration, onOpenChange], ); const [open = false, setOpen] = useControllableState({ prop: openProp, defaultProp: defaultOpen, onChange: handleOpenChange, }); const stateAttribute = useMemo(() => { return open ? (wasOpenDelayedRef.current ? 'delayed-open' : 'instant-open') : 'closed'; }, [open]); const handleOpen = useCallback(() => { window.clearTimeout(openTimerRef.current); openTimerRef.current = 0; wasOpenDelayedRef.current = false; setOpen(true); }, [setOpen]); const handleClose = useCallback(() => { window.clearTimeout(openTimerRef.current); openTimerRef.current = 0; setOpen(false); }, [setOpen]); const handleDelayedOpen = useCallback(() => { window.clearTimeout(openTimerRef.current); openTimerRef.current = window.setTimeout(() => { wasOpenDelayedRef.current = true; setOpen(true); openTimerRef.current = 0; }, delayDuration); }, [delayDuration, setOpen]); useEffect(() => { return () => { if (openTimerRef.current) { window.clearTimeout(openTimerRef.current); openTimerRef.current = 0; } }; }, []); const { tx } = useThemeContext(); const elevation = useElevationContext(); return ( { if (isOpenDelayedRef.current) { handleDelayedOpen(); } else { handleOpen(); } }, [isOpenDelayedRef, handleDelayedOpen, handleOpen])} onTriggerLeave={useCallback(() => { if (disableHoverableContent) { handleClose(); } else { // Clear the timer in case the pointer leaves the trigger before the tooltip is opened. window.clearTimeout(openTimerRef.current); openTimerRef.current = 0; } }, [handleClose, disableHoverableContent])} onOpen={handleOpen} onClose={handleClose} disableHoverableContent={disableHoverableContent} isPointerInTransitRef={isPointerInTransitRef} onPointerInTransitChange={useCallback((inTransit: boolean) => { isPointerInTransitRef.current = inTransit; }, [])} > {content} {children} ); }; TooltipProvider.displayName = TOOLTIP_NAME; /* ------------------------------------------------------------------------------------------------- * TooltipVirtualTrigger * ----------------------------------------------------------------------------------------------- */ const TooltipVirtualTrigger = ({ virtualRef, __scopeTooltip, }: TooltipScopedProps>) => { const popperScope = usePopperScope(__scopeTooltip); return ; }; /* ------------------------------------------------------------------------------------------------- * TooltipTrigger * ----------------------------------------------------------------------------------------------- */ const TRIGGER_NAME = 'TooltipTrigger'; type TooltipTriggerElement = ElementRef; type PrimitiveButtonProps = ComponentPropsWithoutRef; type TooltipTriggerProps = PrimitiveButtonProps & Pick & { content?: string; side?: TooltipSide; onInteract?: (event: SyntheticEvent) => void; }; const TooltipTrigger = forwardRef( (props: TooltipScopedProps, forwardedRef) => { const { __scopeTooltip, onInteract, // TODO(thure): Pass `delayDuration` into the context. delayDuration: _delayDuration, side, content, ...triggerProps } = props; const context = useTooltipContext(TRIGGER_NAME, __scopeTooltip); const ref = useRef(null); const composedRefs = useComposedRefs(forwardedRef, ref); const isPointerDownRef = useRef(false); const hasPointerMoveOpenedRef = useRef(false); const handlePointerUp = useCallback(() => (isPointerDownRef.current = false), []); useEffect(() => { return () => document.removeEventListener('pointerup', handlePointerUp); }, [handlePointerUp]); return ( { if (event.pointerType === 'touch') { return; } if (!hasPointerMoveOpenedRef.current && !context.isPointerInTransitRef.current) { onInteract?.(event); if (event.defaultPrevented) { return; } context.onTriggerChange(ref.current); context.onTriggerEnter(); hasPointerMoveOpenedRef.current = true; } })} onPointerLeave={composeEventHandlers(props.onPointerLeave, () => { context.onTriggerLeave(); hasPointerMoveOpenedRef.current = false; })} onPointerDown={composeEventHandlers(props.onPointerDown, () => { if (context.open) { context.onClose(); } isPointerDownRef.current = true; document.addEventListener('pointerup', handlePointerUp, { once: true }); })} onFocus={props.onFocus} onBlur={composeEventHandlers(props.onBlur, context.onClose)} onClick={composeEventHandlers(props.onClick, context.onClose)} /> ); }, ); TooltipTrigger.displayName = TRIGGER_NAME; /* ------------------------------------------------------------------------------------------------- * TooltipPortal * ----------------------------------------------------------------------------------------------- */ const PORTAL_NAME = 'TooltipPortal'; type PortalContextValue = { forceMount?: true }; const [PortalProvider, usePortalContext] = createTooltipContext(PORTAL_NAME, { forceMount: undefined, }); type PortalProps = ComponentPropsWithoutRef; interface TooltipPortalProps { children?: ReactNode; /** * Specify a container element to portal the content into. */ container?: PortalProps['container']; /** * Used to force mounting when more control is needed. Useful when * controlling animation with React animation libraries. */ forceMount?: true; } const TooltipPortal: FC = (props: TooltipScopedProps) => { const { __scopeTooltip, forceMount, children, container } = props; const context = useTooltipContext(PORTAL_NAME, __scopeTooltip); return ( {children} ); }; TooltipPortal.displayName = PORTAL_NAME; /* ------------------------------------------------------------------------------------------------- * TooltipContent * ----------------------------------------------------------------------------------------------- */ const CONTENT_NAME = 'TooltipContent'; type TooltipContentElement = TooltipContentImplElement; interface TooltipContentProps extends TooltipContentImplProps { /** * Used to force mounting when more control is needed. Useful when * controlling animation with React animation libraries. */ forceMount?: true; } const TooltipContent = forwardRef( (props: TooltipScopedProps, forwardedRef) => { const portalContext = usePortalContext(CONTENT_NAME, props.__scopeTooltip); const { forceMount = portalContext.forceMount, side = 'top', ...contentProps } = props; const context = useTooltipContext(CONTENT_NAME, props.__scopeTooltip); return ( {context.disableHoverableContent ? ( ) : ( )} ); }, ); type Point = { x: number; y: number }; type Polygon = Point[]; type TooltipContentHoverableElement = TooltipContentImplElement; interface TooltipContentHoverableProps extends TooltipContentImplProps {} const TooltipContentHoverable = forwardRef( (props: TooltipScopedProps, forwardedRef) => { const context = useTooltipContext(CONTENT_NAME, props.__scopeTooltip); const ref = useRef(null); const composedRefs = useComposedRefs(forwardedRef, ref); const [pointerGraceArea, setPointerGraceArea] = useState(null); const { trigger, onClose } = context; const content = ref.current; const { onPointerInTransitChange } = context; const handleRemoveGraceArea = useCallback(() => { setPointerGraceArea(null); onPointerInTransitChange(false); }, [onPointerInTransitChange]); const handleCreateGraceArea = useCallback( (event: PointerEvent, hoverTarget: HTMLElement) => { const currentTarget = event.currentTarget as HTMLElement; const exitPoint = { x: event.clientX, y: event.clientY }; const exitSide = getExitSideFromRect(exitPoint, currentTarget.getBoundingClientRect()); const paddedExitPoints = getPaddedExitPoints(exitPoint, exitSide); const hoverTargetPoints = getPointsFromRect(hoverTarget.getBoundingClientRect()); const graceArea = getHull([...paddedExitPoints, ...hoverTargetPoints]); setPointerGraceArea(graceArea); onPointerInTransitChange(true); }, [onPointerInTransitChange], ); useEffect(() => { return () => handleRemoveGraceArea(); }, [handleRemoveGraceArea]); useEffect(() => { if (trigger && content) { const handleTriggerLeave = (event: PointerEvent) => handleCreateGraceArea(event, content); const handleContentLeave = (event: PointerEvent) => handleCreateGraceArea(event, trigger); trigger.addEventListener('pointerleave', handleTriggerLeave); content.addEventListener('pointerleave', handleContentLeave); return () => { trigger.removeEventListener('pointerleave', handleTriggerLeave); content.removeEventListener('pointerleave', handleContentLeave); }; } }, [trigger, content, handleCreateGraceArea, handleRemoveGraceArea]); useEffect(() => { if (pointerGraceArea) { const handleTrackPointerGrace = (event: PointerEvent) => { const target = event.target as HTMLElement; const pointerPosition = { x: event.clientX, y: event.clientY }; const hasEnteredTarget = trigger?.contains(target) || content?.contains(target); const isPointerOutsideGraceArea = !isPointInPolygon(pointerPosition, pointerGraceArea); if (hasEnteredTarget) { handleRemoveGraceArea(); } else if (isPointerOutsideGraceArea) { handleRemoveGraceArea(); onClose(); } }; document.addEventListener('pointermove', handleTrackPointerGrace); return () => document.removeEventListener('pointermove', handleTrackPointerGrace); } }, [trigger, content, pointerGraceArea, onClose, handleRemoveGraceArea]); return ; }, ); const [VisuallyHiddenContentContextProvider, useVisuallyHiddenContentContext] = createTooltipContext(TOOLTIP_NAME, { isInside: false, }); type TooltipContentImplElement = ElementRef; type DismissableLayerProps = ComponentPropsWithoutRef; type PopperContentProps = ComponentPropsWithoutRef; interface TooltipContentImplProps extends Omit { /** * A more descriptive label for accessibility purpose */ 'aria-label'?: string; /** * Event handler called when the escape key is down. * Can be prevented. */ onEscapeKeyDown?: DismissableLayerProps['onEscapeKeyDown']; /** * Event handler called when the a `pointerdown` event happens outside of the `Tooltip`. * Can be prevented. */ onPointerDownOutside?: DismissableLayerProps['onPointerDownOutside']; } const TooltipContentImpl = forwardRef( (props: TooltipScopedProps, forwardedRef) => { const { __scopeTooltip, children, 'aria-label': ariaLabel, onEscapeKeyDown, onPointerDownOutside, ...contentProps } = props; const context = useTooltipContext(CONTENT_NAME, __scopeTooltip); const popperScope = usePopperScope(__scopeTooltip); const { onClose } = context; // Close this tooltip if another one opens useEffect(() => { document.addEventListener(TOOLTIP_OPEN, onClose); return () => document.removeEventListener(TOOLTIP_OPEN, onClose); }, [onClose]); // Close the tooltip if the trigger is scrolled useEffect(() => { if (context.trigger) { const handleScroll = (event: Event) => { const target = event.target as HTMLElement; if (target?.contains(context.trigger)) { onClose(); } }; window.addEventListener('scroll', handleScroll, { capture: true }); return () => window.removeEventListener('scroll', handleScroll, { capture: true }); } }, [context.trigger, onClose]); return ( event.preventDefault()} onDismiss={onClose} > {children} {ariaLabel || children} ); }, ); TooltipContent.displayName = CONTENT_NAME; /* ------------------------------------------------------------------------------------------------- * TooltipArrow * ----------------------------------------------------------------------------------------------- */ const ARROW_NAME = 'TooltipArrow'; type TooltipArrowElement = ElementRef; type PopperArrowProps = ComponentPropsWithoutRef; interface TooltipArrowProps extends PopperArrowProps {} const TooltipArrow = forwardRef( (props: TooltipScopedProps, forwardedRef) => { const { __scopeTooltip, ...arrowProps } = props; const popperScope = usePopperScope(__scopeTooltip); const visuallyHiddenContentContext = useVisuallyHiddenContentContext(ARROW_NAME, __scopeTooltip); // if the arrow is inside the `VisuallyHidden`, we don't want to render it all to // prevent issues in positioning the arrow due to the duplicate return visuallyHiddenContentContext.isInside ? null : ( ); }, ); TooltipArrow.displayName = ARROW_NAME; /* ----------------------------------------------------------------------------------------------- */ type TooltipSide = NonNullable; const getExitSideFromRect = (point: Point, rect: DOMRect): TooltipSide => { const top = Math.abs(rect.top - point.y); const bottom = Math.abs(rect.bottom - point.y); const right = Math.abs(rect.right - point.x); const left = Math.abs(rect.left - point.x); switch (Math.min(top, bottom, right, left)) { case left: return 'left'; case right: return 'right'; case top: return 'top'; case bottom: return 'bottom'; default: throw new Error('unreachable'); } }; const getPaddedExitPoints = (exitPoint: Point, exitSide: TooltipSide, padding = 5) => { const paddedExitPoints: Point[] = []; switch (exitSide) { case 'top': paddedExitPoints.push( { x: exitPoint.x - padding, y: exitPoint.y + padding }, { x: exitPoint.x + padding, y: exitPoint.y + padding }, ); break; case 'bottom': paddedExitPoints.push( { x: exitPoint.x - padding, y: exitPoint.y - padding }, { x: exitPoint.x + padding, y: exitPoint.y - padding }, ); break; case 'left': paddedExitPoints.push( { x: exitPoint.x + padding, y: exitPoint.y - padding }, { x: exitPoint.x + padding, y: exitPoint.y + padding }, ); break; case 'right': paddedExitPoints.push( { x: exitPoint.x - padding, y: exitPoint.y - padding }, { x: exitPoint.x - padding, y: exitPoint.y + padding }, ); break; } return paddedExitPoints; }; const getPointsFromRect = (rect: DOMRect) => { const { top, right, bottom, left } = rect; return [ { x: left, y: top }, { x: right, y: top }, { x: right, y: bottom }, { x: left, y: bottom }, ]; }; // Determine if a point is inside of a polygon. // Based on https://github.com/substack/point-in-polygon const isPointInPolygon = (point: Point, polygon: Polygon) => { const { x, y } = point; let inside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const xi = polygon[i].x; const yi = polygon[i].y; const xj = polygon[j].x; const yj = polygon[j].y; // prettier-ignore const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi); if (intersect) { inside = !inside; } } return inside; }; // Returns a new array of points representing the convex hull of the given set of points. // https://www.nayuki.io/page/convex-hull-algorithm const getHull =

(points: Readonly>): Array

=> { const newPoints: Array

= points.slice(); newPoints.sort((a: Point, b: Point) => { if (a.x < b.x) { return -1; } else if (a.x > b.x) { return +1; } else if (a.y < b.y) { return -1; } else if (a.y > b.y) { return +1; } else { return 0; } }); return getHullPresorted(newPoints); }; // Returns the convex hull, assuming that each points[i] <= points[i + 1]. Runs in O(n) time. const getHullPresorted =

(points: Readonly>): Array

=> { if (points.length <= 1) { return points.slice(); } const upperHull: Array

= []; for (let i = 0; i < points.length; i++) { const p = points[i]; while (upperHull.length >= 2) { const q = upperHull[upperHull.length - 1]; const r = upperHull[upperHull.length - 2]; if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) { upperHull.pop(); } else { break; } } upperHull.push(p); } upperHull.pop(); const lowerHull: Array

= []; for (let i = points.length - 1; i >= 0; i--) { const p = points[i]; while (lowerHull.length >= 2) { const q = lowerHull[lowerHull.length - 1]; const r = lowerHull[lowerHull.length - 2]; if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) { lowerHull.pop(); } else { break; } } lowerHull.push(p); } lowerHull.pop(); if ( upperHull.length === 1 && lowerHull.length === 1 && upperHull[0].x === lowerHull[0].x && upperHull[0].y === lowerHull[0].y ) { return upperHull; } else { return upperHull.concat(lowerHull); } }; export const Tooltip = { Provider: TooltipProvider, Trigger: TooltipTrigger, }; export { createTooltipScope, useTooltipContext }; export type { TooltipProviderProps, TooltipTriggerProps, TooltipScopedProps, TooltipSide };