@carbon/ibm-products
Version:
Carbon for IBM Products
275 lines (263 loc) • 10.1 kB
JavaScript
/**
* 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.
*/
;
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'])
};