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 : }
|