UNPKG

@carbon/ibm-products

Version:

Carbon for IBM Products

288 lines (278 loc) 10.2 kB
/** * Copyright IBM Corp. 2020, 2025 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ 'use strict'; var _rollupPluginBabelHelpers = require('../../_virtual/_rollupPluginBabelHelpers.js'); var React = require('react'); var hooks = require('./utils/hooks.js'); var enums = require('./utils/enums.js'); var context = require('./utils/context.js'); var CoachmarkOverlay = require('./CoachmarkOverlay.js'); var index = require('../../_virtual/index.js'); var reactDom = require('react-dom'); var cx = require('classnames'); var devtools = require('../../global/js/utils/devtools.js'); var settings = require('../../settings.js'); var throttle = require('../../global/js/utils/throttle.js'); var react = require('@carbon/react'); var useIsomorphicEffect = require('../../global/js/hooks/useIsomorphicEffect.js'); // The block part of our conventional BEM class names (blockClass__E--M). const blockClass = `${settings.pkg.prefix}--coachmark`; const overlayBlockClass = `${blockClass}-overlay`; const componentName = 'Coachmark'; const defaults = { align: 'bottom', onClose: () => {}, overlayKind: 'tooltip', theme: 'light', isOpenByDefault: false }; /** * Coachmarks are used to call out specific functionality or concepts * within the UI that may not be intuitive but are important for the * user to gain understanding of the product's main value and discover new use cases. */ exports.Coachmark = /*#__PURE__*/React.forwardRef((_ref, ref) => { let { align = defaults.align, autoAlign, children, className, onClose = defaults.onClose, overlayClassName, overlayKind = defaults.overlayKind, overlayRef, positionTune, portalTarget, target, theme = defaults.theme, isOpenByDefault = defaults.isOpenByDefault, // Collect any other property values passed in. ...rest } = _ref; const isBeacon = overlayKind === enums.COACHMARK_OVERLAY_KIND.TOOLTIP; const isStacked = overlayKind === enums.COACHMARK_OVERLAY_KIND.STACKED; const [isOpen, setIsOpen] = React.useState(isStacked || isOpenByDefault); const [shouldResetPosition, setShouldResetPosition] = React.useState(false); const [targetRect, setTargetRect] = React.useState(); const [targetOffset, setTargetOffset] = React.useState({ x: 0, y: 0 }); const overlayBackupRef = React.useRef(undefined); const backupRef = React.useRef(undefined); const _coachmarkRef = ref || backupRef; const _overlayRef = overlayRef || overlayBackupRef; const portalNode = React.useRef(null); const popoverRef = React.useRef(null); useIsomorphicEffect.useIsomorphicEffect(() => { portalNode.current = portalTarget ? document?.querySelector(portalTarget) ?? document?.querySelector('body') : document?.querySelector('body'); }, [portalTarget]); const closeOverlay = () => { setIsOpen(false); }; const handleClose = React.useCallback(() => { if (isStacked) { // If stacked, do not unmount, // only call its ("parent") onClose method. onClose(); } else { setIsOpen(false); onClose(); } }, [isStacked, onClose]); const escFunction = React.useCallback(event => { if (event.key === 'Escape') { handleClose(); } }, [handleClose]); React.useEffect(() => { document.addEventListener('keydown', escFunction, false); return () => { document.removeEventListener('keydown', escFunction, false); }; }, [escFunction]); const handleTargetClick = e => { setTargetRect(e.target.getBoundingClientRect()); setTargetOffset({ x: e.target.offsetLeft, y: e.target.offsetTop }); if (isBeacon) { // toggle open/closed for beacons setIsOpen(prevIsOpen => !prevIsOpen); } else { // reset position for all other kinds setIsOpen(false); setShouldResetPosition(true); } }; const contextValue = { buttonProps: { 'aria-expanded': isOpen, tabIndex: 0, onClick: handleTargetClick, // Compensate for accidental open/close on double-click. // Only open on double-click. onDoubleClick: handleTargetClick }, closeButtonProps: { onClick: handleClose }, targetRect: targetRect, targetOffset: targetOffset, align: align, positionTune: positionTune, isOpen: isOpen }; const handleResize = throttle.throttle(() => { closeOverlay(); }, 2000); // instead of toggling on/off, // keep open and reset to original position React.useEffect(() => { if (shouldResetPosition) { setShouldResetPosition(false); setIsOpen(true); } }, [shouldResetPosition]); useIsomorphicEffect.useIsomorphicEffect(() => { const overlayPositionStyle = { top: `${(positionTune?.y ?? 0) - 16}px`, left: `${(positionTune?.x ?? 0) - 16}px` }; if (popoverRef.current && popoverRef.current.style && overlayPositionStyle) { const combinedStyle = { position: 'absolute', ...overlayPositionStyle }; Object.assign(popoverRef.current.style, combinedStyle); } }, [popoverRef, positionTune]); // On unmount: // - DO NOT "Close()" the coachmark. // - This triggers a "signal" to close it forever. // - "Closing" should only ever be a user-triggered event. // - DO "hide" the coachmark. // - The app is doing the action for the user. // - The user will have the opportunity to open it again. React.useEffect(() => { return () => setIsOpen(false); }, []); hooks.useClickOutsideElement(_coachmarkRef, _overlayRef, overlayKind, closeOverlay); hooks.useWindowEvent('resize', handleResize); return /*#__PURE__*/React.createElement(context.CoachmarkContext.Provider, { value: contextValue }, /*#__PURE__*/React.createElement("div", _rollupPluginBabelHelpers.extends({ className: cx(blockClass, `${blockClass}__${theme}`, className), ref: _coachmarkRef }, rest, devtools.getDevtoolsProps(componentName)), overlayKind !== 'tooltip' ? /*#__PURE__*/React.createElement(React.Fragment, null, target, isOpen && portalNode?.current && /*#__PURE__*/reactDom.createPortal(/*#__PURE__*/React.createElement(CoachmarkOverlay.CoachmarkOverlay, { ref: _overlayRef, fixedIsVisible: false, kind: overlayKind, onClose: handleClose, theme: theme, className: cx(overlayClassName, `${overlayBlockClass}--is-visible`) }, children), // Default to `document.body` when `portalNode` is `null` portalNode?.current)) : /*#__PURE__*/React.createElement(react.Popover, { highContrast: true, caret: true, ref: popoverRef, align: align, autoAlign: autoAlign, open: isOpen }, target, /*#__PURE__*/React.createElement(react.PopoverContent, null, isOpen && /*#__PURE__*/React.createElement(CoachmarkOverlay.CoachmarkOverlay, { ref: _overlayRef, fixedIsVisible: false, kind: overlayKind, onClose: handleClose, theme: theme, className: cx(overlayClassName, { [`${overlayBlockClass}--is-visible`]: isOpen }) }, children))))); }); const overlayRefType = typeof HTMLElement === 'undefined' ? index.default.object : // eslint-disable-next-line ssr-friendly/no-dom-globals-in-module-scope index.default.instanceOf(HTMLElement); // Return a placeholder if not released and not enabled by feature flag exports.Coachmark = settings.pkg.checkComponentEnabled(exports.Coachmark, componentName); // The display name of the component, used by React. Note that displayName // is used in preference to relying on function.name. exports.Coachmark.displayName = componentName; // The types and DocGen commentary for the component props, // in alphabetical order (for consistency). // See https://www.npmjs.com/package/prop-types#usage. exports.Coachmark.propTypes = { /** * Where to render the Coachmark relative to its target. * Applies only to Floating and Tooltip Coachmarks. * @see COACHMARK_ALIGNMENT */ align: index.default.oneOf(['bottom', 'bottom-left', 'bottom-right', 'left', 'left-top', 'left-bottom', 'right', 'right-top', 'right-bottom', 'top', 'top-left', 'top-right']), /** * Auto aligns the coachmark based on screen boundaries * Applies only to Tooltip Coachmarks. */ autoAlign: index.default.bool, /** * Coachmark should use a single CoachmarkOverlayElements component as a child. * @see CoachmarkOverlayElements */ children: index.default.node.isRequired, /** * Optional class name for this component. */ className: index.default.string, /** * Determines if the coachmark is open by default. * Does nothing if `overlayKind=stacked`. */ isOpenByDefault: index.default.bool, /** * Function to call when the Coachmark closes. */ onClose: index.default.func, /** * Optional class name for the Coachmark Overlay component. */ overlayClassName: index.default.string, /** * What kind or style of Coachmark to render. */ overlayKind: index.default.oneOf(['tooltip', 'floating', 'stacked']), overlayRef: index.default.shape({ current: overlayRefType }), /** * By default, the Coachmark will be appended to the end of `document.body`. * The Coachmark will remain persistent as the user navigates the app until * the user closes the Coachmark. * * Alternatively, the app developer can tightly couple the Coachmark to a DOM * element or other component by specifying a CSS selector. The Coachmark will * remain visible as long as that element remains visible or mounted. When the * element is hidden or component is unmounted, the Coachmark will disappear. */ portalTarget: index.default.string, /** * Fine tune the position of the target in pixels. Applies only to Beacons. */ // @ts-ignore positionTune: index.default.shape({ x: index.default.number, y: index.default.number }), /** * The optional button or beacon that the user will click to show the Coachmark. */ target: index.default.node, /** * Determines the theme of the component. */ theme: index.default.oneOf(['light', 'dark']) };