@carbon/ibm-products
Version:
Carbon for IBM Products
275 lines (263 loc) • 9.88 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.
*/
import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js';
import React__default, { useRef, useState, Children, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import PropTypes from '../../_virtual/index.js';
import cx from 'classnames';
import { getDevtoolsProps } from '../../global/js/utils/devtools.js';
import { pkg } from '../../settings.js';
import { CoachmarkOverlay } from '../Coachmark/CoachmarkOverlay.js';
import { CoachmarkStackHome } from './CoachmarkStackHome.js';
import { CoachmarkTagline } from '../Coachmark/CoachmarkTagline.js';
import { CoachmarkContext } from '../Coachmark/utils/context.js';
import { COACHMARK_OVERLAY_KIND } from '../Coachmark/utils/enums.js';
import { useIsomorphicEffect } from '../../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 = `${pkg.prefix}--coachmark-stack`;
const componentName = 'CoachmarkStack';
const elementBlockClass = `${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.
*/
let CoachmarkStack = /*#__PURE__*/React__default.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 = useRef(null);
useIsomorphicEffect(() => {
portalNode.current = portalTarget ? document?.querySelector(portalTarget) ?? document?.querySelector('body') : document?.querySelector('body');
}, [portalTarget]);
const stackHomeRef = useRef(null);
const stackedCoachmarkRefs = useRef([]);
const [isOpen, setIsOpen] = 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] = useState(0);
// // The parent height and width values to return to after unstacked
const [parentHeight, setParentHeight] = useState();
// parent height = child height when stacked behind a child that is shorter
const childArray = Children.toArray(children);
const mountedRef = useRef(undefined);
// same value as CSS animation speed
const delayMs = 240;
// Unmount or unstack a child
const handleClickNavItem = itemNumber => {
setSelectedItemNumber(itemNumber);
};
const handleClose = 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 = useCallback(event => {
if (event.key === 'Escape') {
if (selectedItemNumber === 0) {
handleClose(true);
} else {
handleClose(false);
}
}
}, [handleClose, selectedItemNumber]);
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
};
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
setTimeout(() => {
if (stackHomeRef.current) {
setParentHeight(stackHomeRef.current.clientHeight + 16);
}
}, 0);
}, [stackHomeRef]);
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 = Children.map(childArray, (child, idx) => {
const mountedClass = mountedRef.current ? `${elementBlockClass}--is-mounted` : '';
return /*#__PURE__*/React__default.createElement(CoachmarkOverlay, {
key: idx,
ref: ref => {
stackedCoachmarkRefs.current[idx] = ref;
},
kind: 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__default.createElement(CoachmarkContext.Provider, {
value: contextValue
}, /*#__PURE__*/React__default.createElement("div", _extends({}, rest, {
className: cx(blockClass, `${pkg.prefix}--coachmark-overlay--stack`, className),
ref: ref
}, getDevtoolsProps(componentName)), /*#__PURE__*/React__default.createElement(CoachmarkTagline, {
title: tagline,
onClose: onClose
}), /*#__PURE__*/React__default.createElement(CoachmarkStackHome, {
ref: stackHomeRef,
className: cx(`${pkg.prefix}--coachmark-overlay`, `${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__*/createPortal(wrappedChildren, portalNode?.current) : null));
});
// Return a placeholder if not released and not enabled by feature flag
CoachmarkStack = pkg.checkComponentEnabled(CoachmarkStack, componentName);
// The display name of the component, used by React. Note that displayName
// is used in preference to relying on function.name.
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.
CoachmarkStack.propTypes = {
/**
* CoachmarkStack should use a single CoachmarkOverlayElements component as a child.
*/
children: PropTypes.node.isRequired,
/**
* Provide an optional class to be applied to the containing node.
*/
className: PropTypes.string,
/**
* The label for the button that will close the Stack
*/
closeButtonLabel: PropTypes.string,
// Pass through to CoachmarkStackHome
/**
* The description of the Coachmark.
*/
description: PropTypes.node.isRequired,
/**
* The labels used to link to the stackable Coachmarks.
*/
navLinkLabels: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
/**
* Function to call when the CoachmarkStack closes.
*/
onClose: PropTypes.func,
/**
* Where in the DOM to render the stack.
* The default is `document.body`.
*/
portalTarget: PropTypes.string,
/**
* Optional prop to render any media like images or animated media.
*/
renderMedia: PropTypes.func,
/**
* The tagline title which will be fixed to the bottom right of the window and will serve as the display trigger.
*/
tagline: PropTypes.string.isRequired,
/**
* Determines the theme of the component.
*/
theme: PropTypes.oneOf(['light', 'dark']),
/**
* The title of the Coachmark.
*/
title: PropTypes.string.isRequired,
/**
* Label's tooltip position
*/
tooltipAlign: PropTypes.oneOf(['top', 'bottom'])
};
export { CoachmarkStack };