@primer/react
Version:
An implementation of GitHub's Primer Design System using React
311 lines (300 loc) • 13.3 kB
JavaScript
import React, { Children, useRef, useState, useMemo, useEffect } from 'react';
import { invariant } from '../utils/invariant.js';
import { warning } from '../utils/warning.js';
import { getAnchoredPosition } from '@primer/behaviors';
import { isSupported, apply } from '@oddbird/popover-polyfill/fn';
import { clsx } from 'clsx';
import classes from './Tooltip.module.css.js';
import VisuallyHidden from '../_VisuallyHidden.js';
import useSafeTimeout from '../hooks/useSafeTimeout.js';
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
import { useId } from '../hooks/useId.js';
import { useProvidedRefOrCreate } from '../hooks/useProvidedRefOrCreate.js';
import { useOnEscapePress } from '../hooks/useOnEscapePress.js';
import { useIsMacOS } from '../hooks/useIsMacOS.js';
import { getAccessibleKeybindingHintString, KeybindingHint } from '../KeybindingHint/KeybindingHint.js';
// map tooltip direction to anchoredPosition props
const directionToPosition = {
nw: {
side: 'outside-top',
align: 'end'
},
n: {
side: 'outside-top',
align: 'center'
},
ne: {
side: 'outside-top',
align: 'start'
},
e: {
side: 'outside-right',
align: 'center'
},
se: {
side: 'outside-bottom',
align: 'start'
},
s: {
side: 'outside-bottom',
align: 'center'
},
sw: {
side: 'outside-bottom',
align: 'end'
},
w: {
side: 'outside-left',
align: 'center'
}
};
// map anchoredPosition props to tooltip direction
const positionToDirection = {
'outside-top-end': 'nw',
'outside-top-center': 'n',
'outside-top-start': 'ne',
'outside-right-center': 'e',
'outside-bottom-start': 'se',
'outside-bottom-center': 's',
'outside-bottom-end': 'sw',
'outside-left-center': 'w'
};
// The list is from GitHub's custom-axe-rules https://github.com/github/github/blob/master/app/assets/modules/github/axe-custom-rules.ts#L3
const interactiveElements = ['a[href]', 'button:not([disabled])', 'summary', 'select', 'input:not([type=hidden])', 'textarea'];
// Map delay prop to actual time in ms
// For context on delay times, see https://github.com/github/primer/issues/3313#issuecomment-3336696699
const delayTimeMap = {
short: 50,
medium: 400,
long: 1200
};
const isInteractive = element => {
return interactiveElements.some(selector => element.matches(selector)) || element.hasAttribute('role') && element.getAttribute('role') === 'button';
};
const TooltipContext = /*#__PURE__*/React.createContext({});
const Tooltip = /*#__PURE__*/React.forwardRef(({
direction = 's',
text,
type = 'description',
children,
id,
className,
keybindingHint,
delay = 'short',
_privateDisableTooltip = false,
...rest
}, forwardedRef) => {
const tooltipId = useId(id);
const child = Children.only(children);
const triggerRef = useProvidedRefOrCreate(forwardedRef);
const tooltipElRef = useRef(null);
const [calculatedDirection, setCalculatedDirection] = useState(direction);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const openTimeoutRef = React.useRef(null);
const {
safeSetTimeout,
safeClearTimeout
} = useSafeTimeout();
const openTooltip = () => {
try {
if (tooltipElRef.current && triggerRef.current && tooltipElRef.current.hasAttribute('popover') && !tooltipElRef.current.matches(':popover-open') && !_privateDisableTooltip) {
const tooltip = tooltipElRef.current;
const trigger = triggerRef.current;
tooltip.showPopover();
setIsPopoverOpen(true);
/*
* TOOLTIP POSITIONING
*/
const settings = {
side: directionToPosition[direction].side,
align: directionToPosition[direction].align
};
const {
top,
left,
anchorAlign,
anchorSide
} = getAnchoredPosition(tooltip, trigger, settings);
// This is required to make sure the popover is positioned correctly i.e. when there is not enough space on the specified direction, we set a new direction to position the ::after
const calculatedDirection = positionToDirection[`${anchorSide}-${anchorAlign}`];
setCalculatedDirection(calculatedDirection);
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
}
} catch (error) {
// older browsers don't support the :popover-open selector and will throw, even though we use a polyfill
// see https://github.com/github/issues/issues/12468
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string' && error.message.includes('not a valid selector')) ; else {
throw error;
}
}
};
const closeTooltip = () => {
if (openTimeoutRef.current) {
safeClearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null;
}
try {
if (tooltipElRef.current && triggerRef.current && tooltipElRef.current.hasAttribute('popover') && tooltipElRef.current.matches(':popover-open')) {
tooltipElRef.current.hidePopover();
setIsPopoverOpen(false);
} else {
setIsPopoverOpen(false);
}
} catch (error) {
// older browsers don't support the :popover-open selector and will throw, even though we use a polyfill
// see https://github.com/github/issues/issues/12468
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string' && error.message.includes('not a valid selector')) ; else {
throw error;
}
}
};
// context value
const value = useMemo(() => ({
tooltipId
}), [tooltipId]);
useEffect(() => {
if (!tooltipElRef.current || !triggerRef.current) return;
/*
* ACCESSIBILITY CHECKS
*/
// Has trigger element or any of its children interactive elements?
const isTriggerInteractive = isInteractive(triggerRef.current);
const triggerChildren = triggerRef.current.childNodes;
// two levels deep
const hasInteractiveDescendant = Array.from(triggerChildren).some(child => {
return child instanceof HTMLElement && isInteractive(child) || Array.from(child.childNodes).some(grandChild => grandChild instanceof HTMLElement && isInteractive(grandChild));
});
!(isTriggerInteractive || hasInteractiveDescendant) ? process.env.NODE_ENV !== "production" ? invariant(false, 'The `Tooltip` component expects a single React element that contains interactive content. Consider using a `<button>` or equivalent interactive element instead.') : invariant(false) : void 0;
// If the tooltip is used for labelling the interactive element, the trigger element or any of its children should not have aria-label
if (type === 'label') {
const hasAriaLabel = triggerRef.current.hasAttribute('aria-label');
const hasAriaLabelInChildren = Array.from(triggerRef.current.childNodes).some(child => child instanceof HTMLElement && child.hasAttribute('aria-label'));
process.env.NODE_ENV !== "production" ? warning(hasAriaLabel || hasAriaLabelInChildren, 'The label type `Tooltip` is going to be used here to label the trigger element. Please remove the aria-label from the trigger element.') : void 0;
}
// SSR safe polyfill apply
if (typeof window !== 'undefined') {
if (!isSupported()) {
apply();
}
}
const tooltip = tooltipElRef.current;
tooltip.setAttribute('popover', 'auto');
}, [tooltipElRef, triggerRef, direction, type]);
useOnEscapePress(event => {
if (isPopoverOpen) {
event.stopImmediatePropagation();
event.preventDefault();
closeTooltip();
}
}, [isPopoverOpen]);
const isMacOS = useIsMacOS();
const hasAriaLabel = 'aria-label' in rest;
return /*#__PURE__*/jsx(TooltipContext.Provider, {
value: value,
children: /*#__PURE__*/jsxs(Fragment, {
children: [/*#__PURE__*/React.isValidElement(child) &&
/*#__PURE__*/
// eslint-disable-next-line react-hooks/refs
React.cloneElement(child, {
// @ts-expect-error it needs a non nullable ref
ref: triggerRef,
// If it is a type description, we use tooltip to describe the trigger
'aria-describedby': (() => {
// If tooltip is not a description type, keep the original aria-describedby
if (type !== 'description') {
return child.props['aria-describedby'];
}
// If tooltip is a description type, append our tooltipId
const existingDescribedBy = child.props['aria-describedby'];
if (existingDescribedBy) {
return `${existingDescribedBy} ${tooltipId}`;
}
// If no existing aria-describedby, use our tooltipId
return tooltipId;
})(),
// If it is a label type, we use tooltip to label the trigger
'aria-labelledby': type === 'label' ? tooltipId : child.props['aria-labelledby'],
onBlur: event => {
var _child$props$onBlur, _child$props;
closeTooltip();
(_child$props$onBlur = (_child$props = child.props).onBlur) === null || _child$props$onBlur === void 0 ? void 0 : _child$props$onBlur.call(_child$props, event);
},
onTouchEnd: event => {
var _child$props$onTouchE, _child$props2;
(_child$props$onTouchE = (_child$props2 = child.props).onTouchEnd) === null || _child$props$onTouchE === void 0 ? void 0 : _child$props$onTouchE.call(_child$props2, event);
// Hide tooltips on tap to essentially disable them on touch devices;
// this still allows viewing the tooltip on tap-and-hold
safeSetTimeout(() => closeTooltip(), 10);
},
onFocus: event => {
var _child$props$onFocus, _child$props3;
// only show tooltip on :focus-visible, not on :focus
try {
if (!event.target.matches(':focus-visible')) return;
} catch (_error) {
// jsdom (jest) does not support `:focus-visible` yet and would throw an error
// https://github.com/jsdom/jsdom/issues/3426
}
openTooltip();
(_child$props$onFocus = (_child$props3 = child.props).onFocus) === null || _child$props$onFocus === void 0 ? void 0 : _child$props$onFocus.call(_child$props3, event);
},
onMouseOverCapture: event => {
const delayTime = delayTimeMap[delay] || 50;
// We use a `capture` event to ensure this is called first before
// events that might cancel the opening timeout (like `onTouchEnd`)
// show tooltip after mouse has been hovering for the specified delay time
// (prevent showing tooltip when mouse is just passing through)
openTimeoutRef.current = safeSetTimeout(() => {
var _child$props$onMouseE, _child$props4;
// if the mouse is already moved out, do not show the tooltip
if (!openTimeoutRef.current) return;
openTooltip();
(_child$props$onMouseE = (_child$props4 = child.props).onMouseEnter) === null || _child$props$onMouseE === void 0 ? void 0 : _child$props$onMouseE.call(_child$props4, event);
}, delayTime);
},
onMouseLeave: event => {
var _child$props$onMouseL, _child$props5;
closeTooltip();
(_child$props$onMouseL = (_child$props5 = child.props).onMouseLeave) === null || _child$props$onMouseL === void 0 ? void 0 : _child$props$onMouseL.call(_child$props5, event);
}
}), /*#__PURE__*/jsx("span", {
className: clsx(className, classes.Tooltip),
ref: tooltipElRef,
"data-direction": calculatedDirection,
...rest,
// Only need tooltip role if the tooltip is a description for supplementary information
role: type === 'description' ? 'tooltip' : undefined
// stop AT from announcing the tooltip twice: when it is a label type it will be announced with "aria-labelledby",when it is a description type it will be announced with "aria-describedby"
,
"aria-hidden": true
// mouse leave and enter on the tooltip itself is needed to keep the tooltip open when the mouse is over the tooltip
,
onMouseEnter: openTooltip,
onMouseLeave: closeTooltip
// If there is an aria-label prop, always assign the ID to the parent so the accessible label can be overridden
,
id: hasAriaLabel || !keybindingHint ? tooltipId : undefined,
children: keybindingHint ? /*#__PURE__*/jsxs(Fragment, {
children: [/*#__PURE__*/jsxs("span", {
id: hasAriaLabel ? undefined : tooltipId,
children: [text, /*#__PURE__*/jsxs(VisuallyHidden, {
children: ["(", getAccessibleKeybindingHintString(keybindingHint, isMacOS), ")"]
})]
}), /*#__PURE__*/jsx("span", {
className: clsx(classes.KeybindingHintContainer, text && classes.HasTextBefore),
"aria-hidden": true,
children: /*#__PURE__*/jsx(KeybindingHint, {
keys: keybindingHint,
format: "condensed",
variant: "onEmphasis",
size: "small"
})
})]
}) : text
})]
})
});
});
Tooltip.__SLOT__ = Symbol('Tooltip');
export { Tooltip, TooltipContext };