UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

311 lines (300 loc) • 13.3 kB
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 };