@carbon/ibm-products
Version:
Carbon for IBM Products
512 lines (489 loc) • 20.2 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 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;