UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

234 lines (233 loc) • 9.78 kB
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 };