UNPKG

@carbon/ibm-products

Version:

Carbon for IBM Products

512 lines (489 loc) 20.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 useResizeObserver = require('../../global/js/hooks/useResizeObserver.js'); var index = require('../../_virtual/index.js'); var cx = require('classnames'); var settings = require('../../settings.js'); var pconsole = require('../../global/js/utils/pconsole.js'); var getNodeTextContent = require('../../global/js/utils/getNodeTextContent.js'); var propsHelper = require('../../global/js/utils/props-helper.js'); var react = require('@carbon/react'); var ActionSet = require('../ActionSet/ActionSet.js'); var Wrap = require('../../global/js/utils/Wrap.js'); var usePortalTarget = require('../../global/js/hooks/usePortalTarget.js'); var useFocus = require('../../global/js/hooks/useFocus.js'); var usePreviousValue = require('../../global/js/hooks/usePreviousValue.js'); var useIsomorphicEffect = require('../../global/js/hooks/useIsomorphicEffect.js'); // The block part of our conventional BEM class names (bc__E--M). const bc = `${settings.pkg.prefix}--tearsheet`; const componentName = 'TearsheetShell'; const maxDepth = 3; // NOTE: the component SCSS is not imported here: it is rolled up separately. // Global data structure to communicate the state of tearsheet stacking // (i.e. when more than one tearsheet is open). Each tearsheet supplies a // handler to be called whenever the stacking of the tearsheets changes, which // happens when a tearsheet opens or closes. The 'open' array contains one // handler per OPEN tearsheet ordered from lowest to highest in visual z-order. // The 'all' array contains all the handlers for open and closed tearsheets. // The 'sizes' array contains an array of the sizes for every stacked tearsheet. // This is so we can opt-out of including the stacking scale effect when there // are stacked tearsheets with mixed sizes (ie, using wide and narrow together) const stack = { open: [], all: [], sizes: [] }; // these props are only applicable when size='wide' const tearsheetShellWideProps = ['headerActions', 'influencer', 'influencerPosition', 'influencerWidth', 'navigation']; const tearsheetIsPassive = actions => !actions || !actions?.length; const tearsheetHasCloseIcon = (actions, hasCloseIcon) => hasCloseIcon ?? tearsheetIsPassive(actions); /** * Since the Tearsheet has an H3 heading, any headings inside the Tearsheet should start at H4. * This is a helper to do that. */ const SectionLevel3 = _ref => { let { children, ...rest } = _ref; return /*#__PURE__*/React.createElement(react.Section, _rollupPluginBabelHelpers.extends({ level: 3 }, rest), children); }; /** * TearSheetShell is used internally by TearSheet and TearSheetNarrow * * The component is not public. * * See the canvas tab for the component API details. * */ const TearsheetShell = /*#__PURE__*/React.forwardRef((_ref2, ref) => { let { // The component props, in alphabetical order (for consistency). actions, decorator, ariaLabel, children, className, closeIconDescription = 'Close', currentStep, description, hasCloseIcon, hasError, headerActions, influencer, influencerPosition, influencerWidth, label, navigation, onClose, open, portalTarget: portalTargetIn, selectorPrimaryFocus, selectorsFloatingMenus = [], size, slug: deprecated_slug, title, verticalPosition, launcherButtonRef, // Collect any other property values passed in. ...rest } = _ref2; const carbonPrefix = react.usePrefix(); const bcModalHeader = `${carbonPrefix}--modal-header`; const renderPortalUse = usePortalTarget.usePortalTarget(portalTargetIn); const localRef = React.useRef(undefined); const resizer = React.useRef(null); const modalBodyRef = React.useRef(null); const modalRef = ref || localRef; const { width } = useResizeObserver.useResizeObserver(resizer); const prevOpen = usePreviousValue.usePreviousValue(open); const { keyDownListener, claimFocus } = useFocus.useFocus(modalRef, selectorPrimaryFocus); modalRef.current; const wide = size === 'wide'; // Keep track of the stack depth and our position in it (1-based, 0=closed) const [depth, setDepth] = React.useState(0); const [position, setPosition] = React.useState(0); // Keep a record of the previous value of depth. const prevDepth = React.useRef(undefined); React.useEffect(() => { prevDepth.current = depth; }); // A "passive" tearsheet is one with no navigation actions. const isPassive = tearsheetIsPassive(actions); const effectiveHasCloseIcon = tearsheetHasCloseIcon(actions, hasCloseIcon); // Callback that will be called whenever the stacking order changes. // position is 1-based with 0 indicating closed. function handleStackChange(newDepth, newPosition) { setDepth(newDepth); setPosition(newPosition); } React.useEffect(() => { if (open) { claimFocus(); } }, [open, currentStep, effectiveHasCloseIcon, claimFocus]); React.useEffect(() => { if (prevOpen && !open && launcherButtonRef?.current) { setTimeout(() => { launcherButtonRef?.current.focus(); }, 0); } }, [open, prevOpen, launcherButtonRef]); React.useEffect(() => { requestAnimationFrame(() => { if (open && depth === position && !modalRef?.current?.contains(document.activeElement)) { claimFocus(); } }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [claimFocus, depth, modalRef, position]); React.useEffect(() => { if (hasError && !modalRef?.current?.contains(document.activeElement)) { claimFocus(); } }, [claimFocus, hasError, modalRef]); React.useEffect(() => { const notify = () => stack.all.forEach(handler => { handler(Math.min(stack.open.length, maxDepth), stack.open.indexOf(handler) + 1); }); // Register this tearsheet's stack change callback/listener. stack.all.push(handleStackChange); stack.sizes.push(size); // If the tearsheet is mounting with open=true or open is changing from // false to true to open it then append its notification callback to // the end of the stack array (as its ID), and call all the callbacks // to notify all open tearsheets that the stacking has changed. if (open) { stack.open.push(handleStackChange); notify(); } // Cleanup function called whenever the tearsheet unmounts or the open // prop changes value (in which case it is called prior to this hook // being called again). return function cleanup() { // Remove the notification callback from the all handlers array. stack.all.splice(stack.all.indexOf(handleStackChange), 1); stack.sizes.splice(stack.sizes.indexOf(size), 1); // Remove the notification callback from the open handlers array, if // it's there, and notify all open tearsheets that the stacking has // changed (only necessary if this tearsheet was open). const openIndex = stack.open.indexOf(handleStackChange); if (openIndex >= 0) { stack.open.splice(openIndex, 1); notify(); } }; }, [open, size]); const areAllSameSizeVariant = () => new Set(stack.sizes).size === 1; useIsomorphicEffect.useIsomorphicEffect(() => { const setScaleValues = () => { if (!areAllSameSizeVariant()) { return { [`--${bc}--stacking-scale-factor-single`]: 1, [`--${bc}--stacking-scale-factor-double`]: 1 }; } return { [`--${bc}--stacking-scale-factor-single`]: (width - 32) / width, [`--${bc}--stacking-scale-factor-double`]: (width - 64) / width }; }; if (modalRef.current) { Object.entries(setScaleValues()).map(_ref3 => { let [key, value] = _ref3; modalRef.current.style.setProperty(key, String(value)); }); } }, [modalRef, width]); if (position <= depth) { // Include a modal header if and only if one or more of these is given. // We can't use a Wrap for the ModalHeader because ComposedModal requires // the direct child to be the ModalHeader instance. const includeHeader = label || title || description || headerActions || navigation || effectiveHasCloseIcon; // Include an ActionSet if and only if one or more actions is given. const includeActions = actions && actions?.length > 0; const areAllSameSizeVariant = () => new Set(stack.sizes).size === 1; return renderPortalUse(/*#__PURE__*/React.createElement(react.unstable_FeatureFlags, { enableExperimentalFocusWrapWithoutSentinels: true }, /*#__PURE__*/React.createElement(react.ComposedModal, _rollupPluginBabelHelpers.extends({}, rest, { "aria-label": ariaLabel || getNodeTextContent.getNodeTextContent(title), className: cx(bc, className, { [`${bc}--stacked-${position}-of-${depth}`]: // Don't apply this on the initial open of a single tearsheet. depth > 1 || depth === 1 && (prevDepth?.current ?? 0) > 1, [`${bc}--wide`]: wide, [`${bc}--narrow`]: !wide, [`${bc}--has-slug`]: deprecated_slug, [`${bc}--has-ai-label`]: !!decorator && decorator['type']?.displayName === 'AILabel', [`${bc}--has-decorator`]: !!decorator && decorator['type']?.displayName !== 'AILabel', [`${bc}--has-close`]: effectiveHasCloseIcon }), decorator: decorator || deprecated_slug, containerClassName: cx(`${bc}__container`, { [`${bc}__container--lower`]: verticalPosition === 'lower', [`${bc}__container--mixed-size-stacking`]: !areAllSameSizeVariant() }), onClose, open, selectorPrimaryFocus, onKeyDown: keyDownListener, preventCloseOnClickOutside: !isPassive, ref: modalRef, selectorsFloatingMenus: [`.${carbonPrefix}--overflow-menu-options`, `.${carbonPrefix}--tooltip`, '.flatpickr-calendar', `.${bc}__container`, ...selectorsFloatingMenus], size: "sm" }), includeHeader && /*#__PURE__*/React.createElement(react.ModalHeader, { className: cx(`${bc}__header`, { [`${bc}__header--with-close-icon`]: effectiveHasCloseIcon, [`${bc}__header--with-nav`]: navigation }), closeClassName: cx({ [`${bc}__header--no-close-icon`]: !effectiveHasCloseIcon }), closeModal: onClose, iconDescription: closeIconDescription }, /*#__PURE__*/React.createElement(Wrap.Wrap, { className: `${bc}__header-content`, element: wide ? react.Layer : undefined }, /*#__PURE__*/React.createElement(Wrap.Wrap, { className: `${bc}__header-fields` }, /*#__PURE__*/React.createElement(Wrap.Wrap, { element: "h2", className: `${bcModalHeader}__label` }, label), /*#__PURE__*/React.createElement(Wrap.Wrap, { element: "h3", className: cx(`${bcModalHeader}__heading`, `${bc}__heading`) }, title), /*#__PURE__*/React.createElement(Wrap.Wrap, { className: `${bc}__header-description` }, description)), /*#__PURE__*/React.createElement(Wrap.Wrap, { className: `${bc}__header-actions` }, headerActions)), /*#__PURE__*/React.createElement(Wrap.Wrap, { className: `${bc}__header-navigation` }, navigation)), /*#__PURE__*/React.createElement(Wrap.Wrap, { ref: modalBodyRef, className: `${carbonPrefix}--modal-content ${bc}__body` }, /*#__PURE__*/React.createElement(Wrap.Wrap, { className: cx({ [`${bc}__influencer`]: true, [`${bc}__influencer--wide`]: influencerWidth === 'wide' }), neverRender: influencerPosition === 'right', element: SectionLevel3 }, influencer), /*#__PURE__*/React.createElement(Wrap.Wrap, { className: `${bc}__right` }, /*#__PURE__*/React.createElement(Wrap.Wrap, { className: `${bc}__main`, alwaysRender: includeActions }, /*#__PURE__*/React.createElement(Wrap.Wrap, { className: `${bc}__content`, alwaysRender: !!(influencer && influencerPosition === 'right'), element: SectionLevel3 }, children), /*#__PURE__*/React.createElement(Wrap.Wrap, { className: cx({ [`${bc}__influencer`]: true, [`${bc}__influencer--wide`]: influencerWidth === 'wide' }), neverRender: influencerPosition !== 'right', element: SectionLevel3 }, influencer)), includeActions && /*#__PURE__*/React.createElement(Wrap.Wrap, { className: `${bc}__button-container` }, /*#__PURE__*/React.createElement(ActionSet.ActionSet, { actions: actions, buttonSize: wide ? '2xl' : undefined, className: `${bc}__buttons`, size: wide ? '2xl' : 'lg', "aria-hidden": !open })))), /*#__PURE__*/React.createElement("div", { className: `${bc}__resize-detector`, ref: resizer })))); } else { pconsole.default.warn('Tearsheet not rendered: maximum stacking depth exceeded.'); return null; } }); // The display name of the component, used by React. Note that displayName // is used in preference to relying on function.name. TearsheetShell.displayName = componentName; const portalType = typeof Element === 'undefined' ? index.default.object : // eslint-disable-next-line ssr-friendly/no-dom-globals-in-module-scope index.default.instanceOf(Element); const deprecatedProps = { /** * @deprecated Property replaced by `decorator` */ slug: propsHelper.deprecateProp(index.default.node, 'Property replaced by `decorator`'), /** * **Deprecated** * * The position of the top of tearsheet in the viewport. The 'normal' * position is a short distance down from the top of the * viewport, leaving room at the top for a global header bar to show through * from below. The 'lower' position (the default) provides a little extra room at the top * to allow an action bar navigation or breadcrumbs to also show through. */ verticalPosition: index.default.oneOf(['normal', 'lower']) }; // The types and DocGen commentary for the component props, // in alphabetical order (for consistency). // See https://www.npmjs.com/package/prop-types#usage. // Note that the descriptions here should be kept in sync with those for the // corresponding props for Tearsheet and TearsheetNarrow components. TearsheetShell.propTypes = { /** * The actions to be shown as buttons in the action area at the bottom of the * tearsheet. Each action is specified as an object with optional fields * 'label' to supply the button label, 'kind' to select the button kind (must * be 'primary', 'secondary' or 'ghost'), 'loading' to display a loading * indicator, and 'onClick' to receive notifications when the button is * clicked. Additional fields in the object will be passed to the Button * component, and these can include 'disabled', 'ref', 'className', and any * other Button props. Any other fields in the object will be passed through * to the button element as HTML attributes. * * See https://react.carbondesignsystem.com/?path=/docs/components-button--default#component-api */ /**@ts-ignore*/ actions: index.default.arrayOf( // NB we don't include the validator here, as the component wrapping this // one should ensure appropriate validation is done. index.default.shape({ /**@ts-ignore*/ ...react.Button.propTypes, kind: index.default.oneOf(['ghost', 'danger--ghost', 'secondary', 'danger', 'primary']), label: index.default.string, loading: index.default.bool, // we duplicate this Button prop to improve the DocGen here /**@ts-ignore*/ onClick: react.Button.propTypes.onClick })), /** * The main content of the tearsheet. */ children: index.default.node, /** * An optional class or classes to be added to the outermost element. */ className: index.default.string, /** * The accessibility title for the close icon (if shown). * * **Note:** This prop is only required if a close icon is shown, i.e. if * there are a no navigation actions and/or hasCloseIcon is true. */ /**@ts-ignore*/ closeIconDescription: index.default.string, /** * Optional prop that allows you to pass any component. */ decorator: index.default.oneOfType([index.default.node, index.default.bool]), /** * A description of the flow, displayed in the header area of the tearsheet. */ description: index.default.node, /** * Enable a close icon ('x') in the header area of the tearsheet. By default, * (when this prop is omitted, or undefined or null) a tearsheet does not * display a close icon if there are navigation actions ("transactional * tearsheet") and displays one if there are no navigation actions ("passive * tearsheet"), and that behavior can be overridden if required by setting * this prop to either true or false. */ /**@ts-ignore*/ hasCloseIcon: index.default.bool, /** * The content for the header actions area, displayed alongside the title in * the header area of the tearsheet. This is typically a drop-down, or a set * of small buttons, or similar. NB the headerActions is only applicable for * wide tearsheets. */ headerActions: index.default.element, /** * The content for the influencer section of the tearsheet, displayed * alongside the main content. This is typically a menu, or filter, or * progress indicator, or similar. NB the influencer is only applicable for * wide tearsheets. */ influencer: index.default.element, /** * The position of the influencer section, 'left' or 'right'. */ influencerPosition: index.default.oneOf(['left', 'right']), /** * The width of the influencer: 'narrow' (the default) is 256px, and 'wide' * is 320px. */ influencerWidth: index.default.oneOf(['narrow', 'wide']), /** * A label for the tearsheet, displayed in the header area of the tearsheet * to maintain context for the tearsheet (e.g. as the title changes from page * to page of a multi-page task). */ label: index.default.node, /** * Provide a ref to return focus to once the tearsheet is closed. */ /**@ts-ignore */ launcherButtonRef: index.default.any, /** * Navigation content, such as a set of tabs, to be displayed at the bottom * of the header area of the tearsheet. NB the navigation is only applicable * for wide tearsheets. */ navigation: index.default.element, /** * An optional handler that is called when the user closes the tearsheet (by * clicking the close button, if enabled, or clicking outside, if enabled). * Returning `false` here prevents the modal from closing. */ onClose: index.default.func, /** * Specifies whether the tearsheet is currently open. */ open: index.default.bool, /** * The DOM element that the tearsheet should be rendered within. Defaults to document.body. */ /**@ts-ignore*/ portalTarget: portalType, /** * Specify a CSS selector that matches the DOM element that should be * focused when the Modal opens. */ selectorPrimaryFocus: index.default.string, /** * Specify the CSS selectors that match the floating menus. * * See https://react.carbondesignsystem.com/?path=/docs/components-composedmodal--overview#focus-management */ /**@ts-ignore*/ selectorsFloatingMenus: index.default.arrayOf(index.default.string), /** * Specifies the width of the tearsheet, 'narrow' or 'wide'. */ /**@ts-ignore*/ size: index.default.oneOf(['narrow', 'wide']).isRequired, /** * The main title of the tearsheet, displayed in the header area. */ title: index.default.node, ...deprecatedProps }; exports.TearsheetShell = TearsheetShell; exports.deprecatedProps = deprecatedProps; exports.portalType = portalType; exports.tearsheetHasCloseIcon = tearsheetHasCloseIcon; exports.tearsheetIsPassive = tearsheetIsPassive; exports.tearsheetShellWideProps = tearsheetShellWideProps;