// Copyright 2026 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { FC, ReactNode } from 'react'; import React, { createContext, memo, useContext, useEffect, useMemo, useRef, } from 'react'; import { Tooltip, Direction } from 'radix-ui'; import { computeAccessibleName } from 'dom-accessibility-api'; import { tw } from './tw.dom.js'; import { assert } from './_internal/assert.std.js'; import { getElementAriaRole, isAriaWidgetRole, } from './_internal/ariaRoles.dom.js'; import { isTestOrMockEnvironment } from '../environment.std.js'; const { useDirection } = Direction; const Namespace = 'AxoTooltip'; type PhysicalDirection = 'top' | 'bottom' | 'left' | 'right'; export namespace AxoTooltip { /** * The duration from when the mouse enters a tooltip trigger until the * tooltip opens. * * - auto: 700ms (default) * - none: 0ms * - TODO: Other durations? */ export type Delay = 'auto' | 'none'; const Delays: Record = { auto: 700, none: 0, }; /** * How much time the user has to enter another tooltip trigger without * incurring a delay again. * - auto: 300ms (default) * - never: 0ms */ export type SkipDelay = 'auto' | 'never'; const SkipDelays: Record = { auto: 300, never: 0, }; /** * The preferred side of the trigger to render against when open. * Will be reversed when collisions occur. * * - top (default): Above the trigger, flips to bottom. * - bottom: Below the trigger, flips to top. * - inline-start: Left of trigger, or right in RTL language. * - inline-end: Right of trigger, or left in RTL language. */ export type Side = 'top' | 'bottom' | 'inline-start' | 'inline-end'; /** * The preferred alignment against the trigger. * - center (default): Try to align the tooltip as center as can fit within * any collision boundaries. * - force-start/force-end: Force the tooltip and trigger to be aligned on * their leading/trailing edges. */ export type Align = 'center' | 'force-start' | 'force-end'; const Aligns: Record = { center: 'center', 'force-start': 'start', 'force-end': 'end', }; export type ExperimentalTimestampFormat = 'testing-only'; /** * Component: * -------------------------------- */ export type ProviderProps = Readonly<{ delay?: Delay; skipDelay?: SkipDelay; children: ReactNode; }>; export const Provider: FC = memo(props => { const { delay = 'auto', skipDelay = 'auto' } = props; const delayDuration = useMemo(() => { return Delays[delay]; }, [delay]); const skipDelayDuration = useMemo(() => { return SkipDelays[skipDelay]; }, [skipDelay]); return ( {props.children} ); }); Provider.displayName = `${Namespace}.Provider`; /** * Component: * ----------------------------------------- */ const DEFAULT_COLLISION_PADDING = 8; type CollisionBoundaryType = Readonly<{ elements: Array; padding: number; }>; const CollisionBoundaryContext = createContext({ elements: [], padding: DEFAULT_COLLISION_PADDING, }); export type CollisionBoundaryProps = Readonly<{ boundary: Element | null; padding?: number; children: ReactNode; }>; export const CollisionBoundary: FC = memo(props => { const { boundary, padding } = props; const context = useContext(CollisionBoundaryContext); const value = useMemo((): CollisionBoundaryType => { return { elements: [...context.elements, boundary], padding: padding ?? DEFAULT_COLLISION_PADDING, // Always reset to default }; }, [context, boundary, padding]); return ( {props.children} ); }); CollisionBoundary.displayName = `${Namespace}.CollisionBoundary`; /** * Component: * ---------------------------- */ function generateTooltipArrowPath(): string { let path = ''; path += 'M 0 0'; // start at top left path += 'Q 3 0, 5 2'; // left inner curve path += 'L 8 5'; // left edge path += 'Q 9 6, 10 6'; // left tip curve path += 'Q 11 6, 12 5'; // right tip curve path += 'L 15 2'; // right edge path += 'Q 17 0, 20 0'; // right inner curve, end at top right path += 'Z'; // close return path; } const TOOLTIP_ARROW_PATH = generateTooltipArrowPath(); const TOOLTIP_ARROW_WIDTH = 20; const TOOLTIP_ARROW_HEIGHT = 6; export type RootConfigProps = Readonly<{ delay?: Delay; side?: Side; align?: Align; label: ReactNode; // TODO(jamie): Need to spec timestamp formats experimentalTimestamp?: number | null; experimentalTimestampFormat?: ExperimentalTimestampFormat; keyboardShortcut?: string | null; }>; export type RootProps = RootConfigProps & Readonly<{ /** * You may sometimes want to use [aria-hidden] when the tooltip is * repeating the same content as [aria-label] which would make it purely * a visual affordance. */ tooltipRepeatsTriggerAccessibleName?: boolean; children: ReactNode; /** @private exported for stories only */ __FORCE_OPEN?: boolean; }>; const rootDisplayName = `${Namespace}.Root`; export const Root: FC = memo(props => { const { delay, side = 'top', align = 'center', keyboardShortcut, experimentalTimestamp, } = props; const direction = useDirection(); const collisionBoundary = useContext(CollisionBoundaryContext); const triggerRef = useRef(null); const physicalDirection = useMemo((): PhysicalDirection => { if (side === 'inline-start') { return direction === 'rtl' ? 'right' : 'left'; } if (side === 'inline-end') { return direction === 'rtl' ? 'left' : 'right'; } return side; }, [side, direction]); const hasArrow = useMemo(() => { return side === 'top' || side === 'bottom'; }, [side]); const delayDuration = useMemo(() => { return delay != null ? Delays[delay] : undefined; }, [delay]); const formattedTimestamp = useMemo(() => { if (experimentalTimestamp == null) { return null; } const formatter = new Intl.DateTimeFormat('en', { timeStyle: 'short' }); return formatter.format(experimentalTimestamp); }, [experimentalTimestamp]); const hasAccessory = useMemo(() => { return keyboardShortcut != null && formattedTimestamp != null; }, [keyboardShortcut, formattedTimestamp]); useEffect(() => { if (isTestOrMockEnvironment()) { assert( triggerRef.current instanceof HTMLElement, `${rootDisplayName} child must forward ref` ); assert( isAriaWidgetRole(getElementAriaRole(triggerRef.current)), `${rootDisplayName} child must have a widget role like 'button'` ); const triggerName = computeAccessibleName(triggerRef.current); assert( triggerName !== '', `${rootDisplayName} child must have an accessible name` ); if (props.tooltipRepeatsTriggerAccessibleName) { return; } assert( triggerName !== props.label, `${rootDisplayName} label must not repeat child trigger's accessible name. ` + 'Use the tooltipRepeatsTriggerAccessibleName prop if you would ' + 'like to make the tooltip presentational only.' ); } }); return ( {props.children} {hasArrow && ( )}
{props.label}
{keyboardShortcut != null && (
{keyboardShortcut}
)} {formattedTimestamp != null && (
{formattedTimestamp}
)}
); }); Root.displayName = rootDisplayName; }