LCOV - code coverage report
Current view: top level - dashql-app/src/ui/foundations - tooltip.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 96.8 % 124 120
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from './tooltip.module.css';
       3              : 
       4              : import { KeyEventHandler, useKeyEvents } from '../../utils/key_events.js';
       5              : import { AnchorAlignment, AnchorSide, getAnchoredPosition } from './anchored_position.js';
       6              : 
       7              : export type TriggerPropsType = {
       8              :     'aria-describedby'?: string
       9              :     'aria-labelledby'?: string
      10              :     'aria-label'?: string
      11              :     onBlur?: React.FocusEventHandler
      12              :     onFocus?: React.FocusEventHandler
      13              :     onMouseEnter?: React.MouseEventHandler
      14              :     onMouseLeave?: React.MouseEventHandler
      15              :     ref?: React.Ref<HTMLElement | null>
      16              : }
      17              : 
      18              : export type TooltipDirection = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
      19              : export interface TooltipProps {
      20              :     direction?: TooltipDirection;
      21              :     text: string
      22              :     type?: 'label' | 'description'
      23              :     children?: React.ReactElement<TriggerPropsType | null>;
      24              : }
      25              : 
      26            1 : const directionToPosition: Record<TooltipDirection, { side: AnchorSide; align: AnchorAlignment }> = {
      27            1 :     nw: { side: AnchorSide.OutsideTop, align: AnchorAlignment.Start },
      28            1 :     n: { side: AnchorSide.OutsideTop, align: AnchorAlignment.Center },
      29            1 :     ne: { side: AnchorSide.OutsideTop, align: AnchorAlignment.End },
      30            1 :     e: { side: AnchorSide.OutsideRight, align: AnchorAlignment.Center },
      31            1 :     se: { side: AnchorSide.OutsideBottom, align: AnchorAlignment.End },
      32            1 :     s: { side: AnchorSide.OutsideBottom, align: AnchorAlignment.Center },
      33            1 :     sw: { side: AnchorSide.OutsideBottom, align: AnchorAlignment.Start },
      34            1 :     w: { side: AnchorSide.OutsideLeft, align: AnchorAlignment.Center },
      35            1 : }
      36              : 
      37            1 : const positionToDirection: Record<number, TooltipDirection> = {
      38            1 :     [(AnchorSide.OutsideTop << 3 | AnchorAlignment.Start)]: 'nw',
      39            1 :     [(AnchorSide.OutsideTop << 3 | AnchorAlignment.Center)]: 'n',
      40            1 :     [(AnchorSide.OutsideTop << 3 | AnchorAlignment.End)]: 'ne',
      41            1 :     [(AnchorSide.OutsideRight << 3 | AnchorAlignment.Center)]: 'e',
      42            1 :     [(AnchorSide.OutsideBottom << 3 | AnchorAlignment.End)]: 'se',
      43            1 :     [(AnchorSide.OutsideBottom << 3 | AnchorAlignment.Center)]: 's',
      44            1 :     [(AnchorSide.OutsideBottom << 3 | AnchorAlignment.Start)]: 'sw',
      45            1 :     [(AnchorSide.OutsideLeft << 3 | AnchorAlignment.Center)]: 'w',
      46            1 : }
      47              : 
      48            1 : export const TooltipContext = React.createContext<{ tooltipId?: string }>({})
      49              : 
      50            1 : export function Tooltip(props: TooltipProps): React.ReactElement {
      51            1 :     const tooltipId = React.useId();
      52            1 :     const triggerRef = React.useRef<HTMLElement | null>(null);
      53            1 :     const tooltipElRef = React.useRef<HTMLDivElement | null>(null)
      54            1 :     const [calculatedDirection, setCalculatedDirection] = React.useState<TooltipDirection | undefined>(props.direction)
      55              : 
      56            1 :     const child = props.children as React.ReactElement<TriggerPropsType | null> | undefined;
      57              : 
      58            1 :     const setTriggerRef = React.useCallback((element: HTMLElement | null) => {
      59            1 :         triggerRef.current = element;
      60            1 :         const childRef = child?.props?.ref;
      61            1 :         if (typeof childRef === 'function') {
      62            1 :             childRef(element);
      63            1 :         } else if (childRef && typeof childRef === 'object') {
      64            1 :             childRef.current = element;
      65              :         }
      66            1 :     }, [child]);
      67              : 
      68            1 :     const openTooltip = () => {
      69            1 :         if (
      70            1 :             tooltipElRef.current &&
      71            1 :             triggerRef.current &&
      72            1 :             tooltipElRef.current.hasAttribute('popover') &&
      73            1 :             !tooltipElRef.current.matches(':popover-open')
      74            1 :         ) {
      75            1 :             const tooltip = tooltipElRef.current
      76            1 :             const trigger = triggerRef.current
      77            1 :             tooltip.showPopover()
      78              : 
      79            1 :             const settings = props.direction ? directionToPosition[props.direction] : undefined;
      80            1 :             const { top, left, anchorAlign, anchorSide } = getAnchoredPosition(tooltip, trigger, settings)
      81            1 :             const calculatedDirection = positionToDirection[(anchorSide << 3) | anchorAlign];
      82            1 :             setCalculatedDirection(calculatedDirection);
      83            1 :             tooltip.style.top = `${top}px`
      84            1 :             tooltip.style.left = `${left}px`
      85            1 :         }
      86            1 :     }
      87              :     // Helper to close a tooltip
      88            1 :     const closeTooltip = () => {
      89            1 :         if (
      90            1 :             tooltipElRef.current &&
      91            1 :             triggerRef.current &&
      92            1 :             tooltipElRef.current.hasAttribute('popover') &&
      93            1 :             tooltipElRef.current.matches(':popover-open')
      94              :         ) {
      95            1 :             tooltipElRef.current.hidePopover()
      96              :         }
      97            1 :     }
      98              :     // context value
      99            1 :     const value = React.useMemo(() => ({ tooltipId }), [tooltipId])
     100              : 
     101            1 :     React.useEffect(() => {
     102            1 :         if (!tooltipElRef.current || !triggerRef.current) return
     103              : 
     104            1 :         const tooltip = tooltipElRef.current
     105            1 :         tooltip.setAttribute('popover', 'auto')
     106            1 :     }, [tooltipElRef, triggerRef, props.direction, props.type])
     107              : 
     108              : 
     109              :     // Setup handler to close the tooltip with escape.
     110              :     // The browser also closes the auto popover, e.g. when another one opens, so ask the popover whether it is open.
     111              :     // A stale flag swallowed every Escape of the page, e.g. the one that closes an overlay.
     112            1 :     const keyEvents = React.useMemo<KeyEventHandler[]>(() => ([{
     113            1 :         key: "Escape",
     114            1 :         ctrlKey: false,
     115            1 :         callback: (event: KeyboardEvent) => {
     116            1 :             if (tooltipElRef.current?.matches(':popover-open')) {
     117            0 :                 event.stopImmediatePropagation()
     118            0 :                 event.preventDefault()
     119            0 :                 closeTooltip()
     120            0 :             }
     121            1 :         }
     122            1 :     }]), []);
     123            1 :     useKeyEvents(keyEvents);
     124              : 
     125            1 :     return (
     126            1 :         <TooltipContext.Provider value={value}>
     127            1 :             <>
     128            1 :                 {child &&
     129            1 :                     React.cloneElement(child, {
     130            1 :                         ref: setTriggerRef,
     131              :                         // If it is a type description, we use tooltip to describe the trigger
     132            1 :                         'aria-describedby': props.type === 'description' ? tooltipId : (child.props ? child.props['aria-describedby'] : undefined),
     133              :                         // If it is a label type, we use tooltip to label the trigger
     134            1 :                         'aria-labelledby': props.type === 'label' && child.props?.['aria-label'] == null
     135            1 :                             ? tooltipId
     136            1 :                             : child.props?.['aria-labelledby'],
     137            1 :                         onBlur: (event: React.FocusEvent) => {
     138            1 :                             closeTooltip()
     139            1 :                             child.props?.onBlur?.(event)
     140            1 :                         },
     141            1 :                         onFocus: (event: React.FocusEvent) => {
     142              :                             // only show tooltip on :focus-visible, not on :focus
     143            1 :                             try {
     144            1 :                                 if (!event.target.matches(':focus-visible')) return
     145            1 :                             } catch (error) {
     146              :                                 // jsdom (jest) does not support `:focus-visible` yet and would throw an error
     147              :                                 // https://github.com/jsdom/jsdom/issues/3426
     148            1 :                             }
     149            1 :                             openTooltip()
     150            1 :                             child.props?.onFocus?.(event)
     151            1 :                         },
     152            1 :                         onMouseEnter: (event: React.MouseEvent) => {
     153            1 :                             openTooltip()
     154            1 :                             child.props?.onMouseEnter?.(event)
     155            1 :                         },
     156            1 :                         onMouseLeave: (event: React.MouseEvent) => {
     157            1 :                             closeTooltip()
     158            1 :                             child.props?.onMouseLeave?.(event)
     159            1 :                         },
     160            1 :                     })
     161              :                 }
     162            1 :                 <div
     163            1 :                     className={styles.tooltip}
     164            1 :                     ref={tooltipElRef}
     165            1 :                     data-direction={calculatedDirection}
     166              : 
     167            1 :                     role={props.type === 'description' ? 'tooltip' : undefined}
     168            1 :                     aria-hidden={props.type === 'label' ? true : undefined}
     169            1 :                     id={tooltipId}
     170              : 
     171            1 :                     onMouseEnter={openTooltip}
     172            1 :                     onMouseLeave={closeTooltip}
     173              :                 >
     174            1 :                     {props.text}
     175            1 :                 </div>
     176            1 :             </>
     177            1 :         </TooltipContext.Provider>
     178              :     );
     179            1 : }
        

Generated by: LCOV version 2.0-1