@primer/react
Version:
An implementation of GitHub's Primer Design System using React
234 lines (233 loc) • 9.78 kB
JavaScript
import { useProvidedRefOrCreate } from "../hooks/useProvidedRefOrCreate.js";
import { useOnEscapePress } from "../hooks/useOnEscapePress.js";
import { useId as useId$1 } from "../hooks/useId.js";
import { warning } from "../utils/warning.js";
import { invariant } from "../utils/invariant.js";
import Tooltip_module_css_default from "./Tooltip.module.css.js";
import VisuallyHidden from "../_VisuallyHidden.js";
import { usePlatform } from "../KeybindingHint/platform.js";
import { KeybindingHint } from "../KeybindingHint/KeybindingHint.js";
import { getAccessibleKeybindingHintString } from "../KeybindingHint/utils.js";
import useSafeTimeout from "../hooks/useSafeTimeout.js";
import { TooltipContext } from "./TooltipContext.js";
import { clsx } from "clsx";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import React, { Children, useEffect, useMemo, useRef, useState } from "react";
import { getAnchoredPosition } from "@primer/behaviors";
import { apply, isSupported } from "@oddbird/popover-polyfill/fn";
//#region src/TooltipV2/Tooltip.tsx
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"
}
};
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"
};
const interactiveElements = [
"a[href]",
"button:not([disabled])",
"summary",
"select",
"input:not([type=hidden])",
"textarea"
];
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 emptyKeybindingHints = [];
const Tooltip = /*#__PURE__*/ React.forwardRef(({ direction = "s", text, type = "description", children, id, className, keybindingHint = emptyKeybindingHints, delay = "short", _privateDisableTooltip = false, ...rest }, forwardedRef) => {
const tooltipId = useId$1(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);
const { top, left, anchorAlign, anchorSide } = getAnchoredPosition(tooltip, trigger, {
side: directionToPosition[direction].side,
align: directionToPosition[direction].align
});
const calculatedDirection = positionToDirection[`${anchorSide}-${anchorAlign}`];
setCalculatedDirection(calculatedDirection);
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
}
} catch (error) {
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) {
if (error && typeof error === "object" && "message" in error && typeof error.message === "string" && error.message.includes("not a valid selector")) {} else throw error;
}
};
const value = useMemo(() => ({ tooltipId }), [tooltipId]);
useEffect(() => {
if (!tooltipElRef.current || !triggerRef.current) return;
const isTriggerInteractive = isInteractive(triggerRef.current);
const triggerChildren = triggerRef.current.childNodes;
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) && invariant(false, "The `Tooltip` component expects a single React element that contains interactive content. Consider using a `<button>` or equivalent interactive element instead.");
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"));
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.");
}
if (typeof window !== "undefined") {
if (!isSupported()) apply();
}
tooltipElRef.current.setAttribute("popover", "auto");
}, [
tooltipElRef,
triggerRef,
direction,
type
]);
useOnEscapePress((event) => {
if (isPopoverOpen) {
event.stopImmediatePropagation();
event.preventDefault();
closeTooltip();
}
}, [isPopoverOpen]);
const platform = usePlatform();
const hasAriaLabel = "aria-label" in rest;
const keybindingHints = Array.isArray(keybindingHint) ? keybindingHint : [keybindingHint];
return /*#__PURE__*/ jsx(TooltipContext.Provider, {
value,
children: /*#__PURE__*/ jsxs(Fragment, { children: [/*#__PURE__*/ React.isValidElement(child) && /*#__PURE__*/ React.cloneElement(child, {
ref: triggerRef,
"aria-describedby": (() => {
if (type !== "description") return child.props["aria-describedby"];
const existingDescribedBy = child.props["aria-describedby"];
if (existingDescribedBy) return `${existingDescribedBy} ${tooltipId}`;
return tooltipId;
})(),
"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 || _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 || _child$props$onTouchE.call(_child$props2, event);
safeSetTimeout(() => closeTooltip(), 10);
},
onFocus: (event) => {
var _child$props$onFocus, _child$props3;
try {
if (!event.target.matches(":focus-visible")) return;
} catch (_error) {}
openTooltip();
(_child$props$onFocus = (_child$props3 = child.props).onFocus) === null || _child$props$onFocus === void 0 || _child$props$onFocus.call(_child$props3, event);
},
onMouseOverCapture: (event) => {
openTimeoutRef.current = safeSetTimeout(() => {
var _child$props$onMouseE, _child$props4;
if (!openTimeoutRef.current) return;
openTooltip();
(_child$props$onMouseE = (_child$props4 = child.props).onMouseEnter) === null || _child$props$onMouseE === void 0 || _child$props$onMouseE.call(_child$props4, event);
}, delayTimeMap[delay] || 50);
},
onMouseLeave: (event) => {
var _child$props$onMouseL, _child$props5;
closeTooltip();
(_child$props$onMouseL = (_child$props5 = child.props).onMouseLeave) === null || _child$props$onMouseL === void 0 || _child$props$onMouseL.call(_child$props5, event);
}
}), /*#__PURE__*/ jsx("span", {
className: clsx(className, Tooltip_module_css_default.Tooltip),
ref: tooltipElRef,
"data-direction": calculatedDirection,
"data-component": "Tooltip",
...rest,
role: type === "description" ? "tooltip" : void 0,
"aria-hidden": true,
onMouseEnter: openTooltip,
onMouseLeave: closeTooltip,
id: hasAriaLabel || keybindingHints.length === 0 ? tooltipId : void 0,
children: keybindingHints.length > 0 ? /*#__PURE__*/ jsxs(Fragment, { children: [/*#__PURE__*/ jsxs("span", {
id: hasAriaLabel ? void 0 : tooltipId,
children: [text, /*#__PURE__*/ jsxs(VisuallyHidden, { children: [
"(",
keybindingHints.map((hint) => getAccessibleKeybindingHintString(hint, platform)).join(" or "),
")"
] })]
}), /*#__PURE__*/ jsx("span", {
className: clsx(Tooltip_module_css_default.KeybindingHintContainer, text && Tooltip_module_css_default.HasTextBefore, keybindingHints.length > 1 && Tooltip_module_css_default.HasMultipleHints),
"aria-hidden": true,
"data-component": "Tooltip.KeybindingHintContainer",
children: keybindingHints.map((hint, i) => /*#__PURE__*/ jsxs(React.Fragment, { children: [i > 0 && " or ", /*#__PURE__*/ jsx(KeybindingHint, {
keys: hint,
format: "condensed",
variant: "onEmphasis",
size: "small"
})] }, `${i}-${hint}`))
})] }) : text
})] })
});
});
Tooltip.__SLOT__ = Symbol("Tooltip");
//#endregion
export { Tooltip };