UNPKG

@carbon/ibm-products

Version:

Carbon for IBM Products

275 lines (263 loc) 10.1 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 reactDom = require('react-dom'); var index = require('../../_virtual/index.js'); var cx = require('classnames'); var devtools = require('../../global/js/utils/devtools.js'); var settings = require('../../settings.js'); var CoachmarkOverlay = require('../Coachmark/CoachmarkOverlay.js'); var CoachmarkStackHome = require('./CoachmarkStackHome.js'); var CoachmarkTagline = require('../Coachmark/CoachmarkTagline.js'); var context = require('../Coachmark/utils/context.js'); var enums = require('../Coachmark/utils/enums.js'); var useIsomorphicEffect = require('../../global/js/hooks/useIsomorphicEffect.js'); // Carbon and package components we use. /* TODO: @import(s) of carbon components and other package components. */ // The block part of our conventional BEM class names (blockClass__E--M). const blockClass = `${settings.pkg.prefix}--coachmark-stack`; const componentName = 'CoachmarkStack'; const elementBlockClass = `${settings.pkg.prefix}--coachmark-stack-element`; const defaults = { onClose: () => {}, // Pass through to CoachmarkStackHome theme: 'light', portalTarget: 'body' }; // NOTE // The stack is limited to a depth of two Coachmarks: // - a single parent CoachmarkStackHome // - a single child Coachmark when stacked // The parent will include links to all the children. // No child Coachmark will include links to any other child Coachmarks. /** * Stacked 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. * This variant allows the stacking of multiple coachmark overlays to be displayed by interacting with the tagline. */ exports.CoachmarkStack = /*#__PURE__*/React.forwardRef((_ref, ref) => { let { children, className, onClose = defaults.onClose, // Pass through to CoachmarkStackHome description, renderMedia, navLinkLabels, portalTarget = defaults.portalTarget, closeButtonLabel, tagline, theme = defaults.theme, title, tooltipAlign, ...rest } = _ref; const portalNode = React.useRef(null); useIsomorphicEffect.useIsomorphicEffect(() => { portalNode.current = portalTarget ? document?.querySelector(portalTarget) ?? document?.querySelector('body') : document?.querySelector('body'); }, [portalTarget]); const stackHomeRef = React.useRef(null); const stackedCoachmarkRefs = React.useRef([]); const [isOpen, setIsOpen] = React.useState(false); // selectedItemNumber -1 = parent close button was clicked, remove entire stack // selectedItemNumber 0 = (default) the parent is visible, all children are hidden // selectedItemNumber 1+ = a child is visible and stacked atop the parent const [selectedItemNumber, setSelectedItemNumber] = React.useState(0); // // The parent height and width values to return to after unstacked const [parentHeight, setParentHeight] = React.useState(); // parent height = child height when stacked behind a child that is shorter const childArray = React.Children.toArray(children); const mountedRef = React.useRef(undefined); // same value as CSS animation speed const delayMs = 240; // Unmount or unstack a child const handleClickNavItem = itemNumber => { setSelectedItemNumber(itemNumber); }; const handleClose = React.useCallback(isParentCloseButton => { if (isParentCloseButton) { // Trigger slide-out animation setSelectedItemNumber(-1); // Unmount after animation is complete const timer = setTimeout(() => { setIsOpen(false); onClose(); }, delayMs); return () => clearTimeout(timer); } else { // Unstack child setSelectedItemNumber(0); } }, [onClose]); const escFunction = React.useCallback(event => { if (event.key === 'Escape') { if (selectedItemNumber === 0) { handleClose(true); } else { handleClose(false); } } }, [handleClose, selectedItemNumber]); React.useEffect(() => { document.addEventListener('keydown', escFunction, false); return () => { document.removeEventListener('keydown', escFunction, false); }; }, [escFunction]); const contextValue = { buttonProps: { tabIndex: 0, 'aria-expanded': isOpen, onClick: () => { setIsOpen(true); }, // Compensate for accidental open/close on double-click. // Only open on double-click. onDoubleClick: () => { setIsOpen(true); } }, closeButtonProps: { onClick: () => handleClose(false) }, isOpen: isOpen }; React.useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); React.useEffect(() => { setTimeout(() => { if (stackHomeRef.current) { setParentHeight(stackHomeRef.current.clientHeight + 16); } }, 0); }, [stackHomeRef]); React.useEffect(() => { const targetSelectedItem = selectedItemNumber - 1; if (!parentHeight) { return; } if (stackHomeRef.current) { stackHomeRef.current.style.height = `${parentHeight}px`; } if (!isOpen || targetSelectedItem < 0) { if (stackHomeRef.current) { stackHomeRef.current.focus(); } return; } const targetHomeHeight = stackedCoachmarkRefs.current[targetSelectedItem].clientHeight; if (stackHomeRef.current) { stackHomeRef.current.style.height = `${targetHomeHeight}px`; stackedCoachmarkRefs.current[targetSelectedItem].focus(); } }, [selectedItemNumber, isOpen, parentHeight]); const wrappedChildren = React.Children.map(childArray, (child, idx) => { const mountedClass = mountedRef.current ? `${elementBlockClass}--is-mounted` : ''; return /*#__PURE__*/React.createElement(CoachmarkOverlay.CoachmarkOverlay, { key: idx, ref: ref => { stackedCoachmarkRefs.current[idx] = ref; }, kind: enums.COACHMARK_OVERLAY_KIND.STACKED, onClose: () => handleClose(false), theme: theme, fixedIsVisible: false, className: cx(elementBlockClass, mountedClass, idx === selectedItemNumber - 1 && `${elementBlockClass}--is-visible`, mountedRef.current && `${elementBlockClass}--is-mounted`) }, child); }); return /*#__PURE__*/React.createElement(context.CoachmarkContext.Provider, { value: contextValue }, /*#__PURE__*/React.createElement("div", _rollupPluginBabelHelpers.extends({}, rest, { className: cx(blockClass, `${settings.pkg.prefix}--coachmark-overlay--stack`, className), ref: ref }, devtools.getDevtoolsProps(componentName)), /*#__PURE__*/React.createElement(CoachmarkTagline.CoachmarkTagline, { title: tagline, onClose: onClose }), /*#__PURE__*/React.createElement(CoachmarkStackHome.CoachmarkStackHome, { ref: stackHomeRef, className: cx(`${settings.pkg.prefix}--coachmark-overlay`, `${settings.pkg.prefix}--coachmark-overlay__${theme}`, elementBlockClass, selectedItemNumber > 0 && `${elementBlockClass}--is-stacked`, selectedItemNumber > 0 && `${elementBlockClass}--is-stacked__${theme}`, isOpen && `${elementBlockClass}--is-visible`, mountedRef.current && `${elementBlockClass}--is-mounted`), isOpen: isOpen && selectedItemNumber < 1, description: description, renderMedia: renderMedia, navLinkLabels: navLinkLabels, onClickNavItem: handleClickNavItem, onClose: () => { handleClose(true); }, portalTarget: portalTarget, closeButtonLabel: closeButtonLabel, title: title, tooltipAlign: tooltipAlign }), portalNode?.current ? /*#__PURE__*/reactDom.createPortal(wrappedChildren, portalNode?.current) : null)); }); // Return a placeholder if not released and not enabled by feature flag exports.CoachmarkStack = settings.pkg.checkComponentEnabled(exports.CoachmarkStack, componentName); // The display name of the component, used by React. Note that displayName // is used in preference to relying on function.name. exports.CoachmarkStack.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.CoachmarkStack.propTypes = { /** * CoachmarkStack should use a single CoachmarkOverlayElements component as a child. */ children: index.default.node.isRequired, /** * Provide an optional class to be applied to the containing node. */ className: index.default.string, /** * The label for the button that will close the Stack */ closeButtonLabel: index.default.string, // Pass through to CoachmarkStackHome /** * The description of the Coachmark. */ description: index.default.node.isRequired, /** * The labels used to link to the stackable Coachmarks. */ navLinkLabels: index.default.arrayOf(index.default.string.isRequired).isRequired, /** * Function to call when the CoachmarkStack closes. */ onClose: index.default.func, /** * Where in the DOM to render the stack. * The default is `document.body`. */ portalTarget: index.default.string, /** * Optional prop to render any media like images or animated media. */ renderMedia: index.default.func, /** * The tagline title which will be fixed to the bottom right of the window and will serve as the display trigger. */ tagline: index.default.string.isRequired, /** * Determines the theme of the component. */ theme: index.default.oneOf(['light', 'dark']), /** * The title of the Coachmark. */ title: index.default.string.isRequired, /** * Label's tooltip position */ tooltipAlign: index.default.oneOf(['top', 'bottom']) };