UNPKG

@carbon/react

Version:

React components for the Carbon Design System

263 lines (255 loc) 8.46 kB
/** * Copyright IBM Corp. 2016, 2023 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js'; import cx from 'classnames'; import PropTypes from 'prop-types'; import React, { useRef, useState, useCallback, useEffect } from 'react'; import { Popover, PopoverContent } from '../Popover/index.js'; import { Escape, Enter, Space } from '../../internal/keyboard/keys.js'; import { match } from '../../internal/keyboard/match.js'; import { useDelayedState } from '../../internal/useDelayedState.js'; import { useId } from '../../internal/useId.js'; import { useNoInteractiveChildren } from '../../internal/useNoInteractiveChildren.js'; import { usePrefix } from '../../internal/usePrefix.js'; import useIsomorphicEffect from '../../internal/useIsomorphicEffect.js'; /** * Event types that trigger a "drag" to stop. */ const DRAG_STOP_EVENT_TYPES = new Set(['mouseup', 'touchend', 'touchcancel']); const Tooltip = /*#__PURE__*/React.forwardRef(({ as, align = 'top', className: customClassName, children, label, description, enterDelayMs = 100, leaveDelayMs = 300, defaultOpen = false, closeOnActivation = false, dropShadow = false, highContrast = true, ...rest }, ref) => { const tooltipRef = useRef(null); const [open, setOpen] = useDelayedState(defaultOpen); const [isDragging, setIsDragging] = useState(false); const [focusByMouse, setFocusByMouse] = useState(false); const [isPointerIntersecting, setIsPointerIntersecting] = useState(false); const id = useId('tooltip'); const prefix = usePrefix(); const child = React.Children.only(children); const { 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy } = child?.props ?? {}; const hasLabel = !!label; const labelledBy = ariaLabelledBy ?? (hasLabel ? id : undefined); const describedBy = ariaDescribedBy ?? (!hasLabel ? id : undefined); const triggerProps = { onFocus: () => !focusByMouse && setOpen(true), onBlur: () => { setOpen(false); setFocusByMouse(false); }, onClick: () => closeOnActivation && setOpen(false), // This should be placed on the trigger in case the element is disabled onMouseEnter, onMouseLeave, onMouseDown, onMouseMove: onMouseMove, onTouchStart: onDragStart, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy }; function getChildEventHandlers(childProps) { const eventHandlerFunctions = Object.keys(triggerProps).filter(prop => prop.startsWith('on')); const eventHandlers = {}; eventHandlerFunctions.forEach(functionName => { eventHandlers[functionName] = evt => { triggerProps[functionName](evt); if (childProps?.[functionName]) { childProps?.[functionName](evt); } }; }); return eventHandlers; } const onKeyDown = useCallback(event => { if (open && match(event, Escape)) { event.stopPropagation(); setOpen(false); } if (open && closeOnActivation && (match(event, Enter) || match(event, Space))) { setOpen(false); } }, [closeOnActivation, open, setOpen]); useIsomorphicEffect(() => { if (!open) { return undefined; } function handleKeyDown(event) { if (match(event, Escape)) { onKeyDown(event); } } document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [open, onKeyDown]); function onMouseEnter() { // Interactive Tags should not support onMouseEnter if (!rest?.onMouseEnter) { setIsPointerIntersecting(true); setOpen(true, enterDelayMs); } } function onMouseDown() { setFocusByMouse(true); onDragStart(); } function onMouseLeave() { setIsPointerIntersecting(false); if (isDragging) { return; } setOpen(false, leaveDelayMs); } function onMouseMove(evt) { if (evt.buttons === 1) { setIsDragging(true); } else { setIsDragging(false); } } function onDragStart() { setIsDragging(true); } const onDragStop = useCallback(() => { setIsDragging(false); // Close the tooltip, unless the mouse pointer is within the bounds of the // trigger. if (!isPointerIntersecting) { setOpen(false, leaveDelayMs); } }, [isPointerIntersecting, leaveDelayMs, setOpen]); useNoInteractiveChildren(tooltipRef, 'The Tooltip component must have no interactive content rendered by the' + '`label` or `description` prop'); useEffect(() => { if (isDragging) { // Register drag stop handlers. DRAG_STOP_EVENT_TYPES.forEach(eventType => { document.addEventListener(eventType, onDragStop); }); } return () => { DRAG_STOP_EVENT_TYPES.forEach(eventType => { document.removeEventListener(eventType, onDragStop); }); }; }, [isDragging, onDragStop]); return /*#__PURE__*/React.createElement(Popover, _extends({ as: as, ref: ref }, rest, { align: align, className: cx(`${prefix}--tooltip`, customClassName), dropShadow: dropShadow, highContrast: highContrast, onKeyDown: onKeyDown, onMouseLeave: onMouseLeave, open: open }), /*#__PURE__*/React.createElement("div", { className: `${prefix}--tooltip-trigger__wrapper` }, typeof child !== 'undefined' ? /*#__PURE__*/React.cloneElement(child, { ...triggerProps, ...getChildEventHandlers(child.props) }) : null), /*#__PURE__*/React.createElement(PopoverContent, { "aria-hidden": open ? 'false' : 'true', className: `${prefix}--tooltip-content`, id: id, onMouseEnter: onMouseEnter, role: "tooltip" }, label || description)); }); Tooltip.propTypes = { /** * Specify how the trigger should align with the tooltip */ align: PropTypes.oneOf(['top', 'top-left', // deprecated use top-start instead 'top-right', // deprecated use top-end instead 'bottom', 'bottom-left', // deprecated use bottom-start instead 'bottom-right', // deprecated use bottom-end instead 'left', 'left-bottom', // deprecated use left-end instead 'left-top', // deprecated use left-start instead 'right', 'right-bottom', // deprecated use right-end instead 'right-top', // deprecated use right-start instead // new values to match floating-ui 'top-start', 'top-end', 'bottom-start', 'bottom-end', 'left-end', 'left-start', 'right-end', 'right-start']), /** * Pass in the child to which the tooltip will be applied */ children: PropTypes.node, /** * Specify an optional className to be applied to the container node */ className: PropTypes.string, /** * Determines wether the tooltip should close when inner content is activated (click, Enter or Space) */ closeOnActivation: PropTypes.bool, /** * Specify whether the tooltip should be open when it first renders */ defaultOpen: PropTypes.bool, /** * Provide the description to be rendered inside of the Tooltip. The * description will use `aria-describedby` and will describe the child node * in addition to the text rendered inside of the child. This means that if you * have text in the child node, that it will be announced alongside the * description to the screen reader. * * Note: if label and description are both provided, label will be used and * description will not be used */ description: PropTypes.node, /** * Specify whether a drop shadow should be rendered */ dropShadow: PropTypes.bool, /** * Specify the duration in milliseconds to delay before displaying the tooltip */ enterDelayMs: PropTypes.number, /** * Render the component using the high-contrast theme */ highContrast: PropTypes.bool, /** * Provide the label to be rendered inside of the Tooltip. The label will use * `aria-labelledby` and will fully describe the child node that is provided. * This means that if you have text in the child node, that it will not be * announced to the screen reader. * * Note: if label and description are both provided, description will not be * used */ label: PropTypes.node, /** * Specify the duration in milliseconds to delay before hiding the tooltip */ leaveDelayMs: PropTypes.number }; export { Tooltip };