@carbon/ibm-products
Version:
Carbon for IBM Products
474 lines (472 loc) • 18.8 kB
JavaScript
/**
* Copyright IBM Corp. 2020, 2026
*
* 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 { __toESM } from "../../_virtual/_rolldown/runtime.js";
import { require_classnames } from "../../node_modules/classnames/index.js";
import pconsole_default from "../../global/js/utils/pconsole.js";
import { pkg } from "../../settings.js";
import { useIsomorphicEffect } from "../../global/js/hooks/useIsomorphicEffect.js";
import { usePreviousValue } from "../../global/js/hooks/usePreviousValue.js";
import { useFocus } from "../../global/js/hooks/useFocus.js";
import { deprecateProp } from "../../global/js/utils/props-helper.js";
import { usePortalTarget } from "../../global/js/hooks/usePortalTarget.js";
import { useResizeObserver } from "../../global/js/hooks/useResizeObserver.js";
import { ActionSet } from "../ActionSet/ActionSet.js";
import { getNodeTextContent } from "../../global/js/utils/getNodeTextContent.js";
import { Wrap } from "../../global/js/utils/Wrap.js";
import { useMergedRefs } from "../../global/js/hooks/useMergedRefs.js";
import { useId } from "../../global/js/utils/useId.js";
import { TearsheetPresence, TearsheetPresenceContext, useExclusiveTearsheetPresenceContext } from "./TearsheetPresence.js";
import React, { cloneElement, isValidElement, useContext, useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { AILabel, Button, ComposedModal, IconButton, Layer, ModalHeader, Section, unstable_FeatureFlags, useFeatureFlag, usePrefix } from "@carbon/react";
import { Close } from "@carbon/react/icons";
//#region src/components/Tearsheet/TearsheetShell.tsx
/**
* Copyright IBM Corp. 2020, 2024
*
* 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 import_classnames = /* @__PURE__ */ __toESM(require_classnames());
const bc = `${pkg.prefix}--tearsheet`;
const componentName = "TearsheetShell";
const maxDepth = 3;
const stack = {
open: [],
all: [],
sizes: []
};
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 = ({ children, ...rest }) => /* @__PURE__ */ React.createElement(Section, {
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 = React.forwardRef(({ open, ...props }, ref) => {
const id = useId();
const enablePresence = useFeatureFlag("enable-presence");
const hasPresenceContext = Boolean(useContext(TearsheetPresenceContext));
const hasPresenceOptIn = enablePresence || hasPresenceContext;
const exclusivePresenceContext = useExclusiveTearsheetPresenceContext(id);
if (hasPresenceOptIn && !exclusivePresenceContext) return /* @__PURE__ */ React.createElement(TearsheetPresence, {
open: open ?? false,
_presenceId: id,
_autoEnablePresence: hasPresenceContext
}, /* @__PURE__ */ React.createElement(TearsheetShellDialog, {
open: true,
ref,
...props
}));
return /* @__PURE__ */ React.createElement(TearsheetShellDialog, {
ref,
open,
...props
});
});
const TearsheetShellDialog = React.forwardRef(({ actions, decorator, ariaLabel, children, className, closeIconDescription = "Close", currentStep, description, hasCloseIcon, hasError, headerActions, influencer, influencerPosition, influencerWidth, label, navigation, onClose, open: externalOpen, portalTarget: portalTargetIn, selectorPrimaryFocus, selectorsFloatingMenus = [], size, slug: deprecated_slug, title, verticalPosition, launcherButtonRef, ...rest }, ref) => {
const carbonPrefix = usePrefix();
const bcModalHeader = `${carbonPrefix}--modal-header`;
const renderPortalUse = usePortalTarget(portalTargetIn);
const localRef = useRef(void 0);
const resizer = useRef(null);
const modalBodyRef = useRef(null);
const modalRef = ref || localRef;
const { width } = useResizeObserver(resizer);
const { keyDownListener, claimFocus } = useFocus(modalRef, selectorPrimaryFocus);
modalRef.current;
const wide = size === "wide";
const presenceContext = useContext(TearsheetPresenceContext);
const mergedRefs = useMergedRefs([modalRef, presenceContext?.presenceRef]);
const enablePresence = useFeatureFlag("enable-presence") || presenceContext?.autoEnablePresence;
const open = externalOpen || enablePresence;
const prevOpen = usePreviousValue(open);
const [depth, setDepth] = useState(0);
const [position, setPosition] = useState(0);
const prevDepth = useRef(void 0);
useEffect(() => {
prevDepth.current = depth;
});
const isPassive = tearsheetIsPassive(actions);
const effectiveHasCloseIcon = tearsheetHasCloseIcon(actions, hasCloseIcon);
function handleStackChange(newDepth, newPosition) {
setDepth(newDepth);
setPosition(newPosition);
}
useEffect(() => {
if (open) claimFocus();
}, [
open,
currentStep,
effectiveHasCloseIcon,
claimFocus
]);
useEffect(() => {
if (!enablePresence && prevOpen && !open && launcherButtonRef?.current) setTimeout(() => {
launcherButtonRef?.current?.focus();
}, 10);
}, [
enablePresence,
open,
prevOpen,
launcherButtonRef
]);
useEffect(() => {
const launcherButton = launcherButtonRef?.current;
if (!enablePresence || !launcherButton) return;
return () => {
setTimeout(() => {
launcherButton.focus();
}, 10);
};
}, [enablePresence, launcherButtonRef]);
useEffect(() => {
requestAnimationFrame(() => {
if (open && depth === position && !modalRef?.current?.contains(document.activeElement)) claimFocus();
});
}, [
claimFocus,
depth,
modalRef,
position
]);
useEffect(() => {
if (hasError && !modalRef?.current?.contains(document.activeElement)) claimFocus();
}, [
claimFocus,
hasError,
modalRef
]);
useEffect(() => {
const isPresent = open && !presenceContext?.isExiting;
const notify = () => stack.all.forEach((handler) => {
handler(Math.min(stack.open.length, maxDepth), stack.open.indexOf(handler) + 1);
});
stack.all.push(handleStackChange);
stack.sizes.push(size);
if (isPresent) {
stack.open.push(handleStackChange);
notify();
}
return function cleanup() {
stack.all.splice(stack.all.indexOf(handleStackChange), 1);
stack.sizes.splice(stack.sizes.indexOf(size), 1);
const openIndex = stack.open.indexOf(handleStackChange);
if (openIndex >= 0) {
stack.open.splice(openIndex, 1);
notify();
}
};
}, [
open,
presenceContext?.isExiting,
size
]);
const areAllSameSizeVariant = () => new Set(stack.sizes).size === 1;
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(([key, value]) => {
modalRef.current.style.setProperty(key, String(value));
});
}, [modalRef, width]);
if (position <= depth) {
const includeHeader = label || title || description || headerActions || navigation || effectiveHasCloseIcon;
const includeActions = actions && actions?.length > 0;
const areAllSameSizeVariant = () => new Set(stack.sizes).size === 1;
const normalizedDecorator = isValidElement(decorator) && decorator.type === AILabel ? cloneElement(decorator, { size: "sm" }) : decorator;
return renderPortalUse(/* @__PURE__ */ React.createElement(unstable_FeatureFlags, { enableExperimentalFocusWrapWithoutSentinels: true }, /* @__PURE__ */ React.createElement(ComposedModal, {
...rest,
"aria-label": ariaLabel || getNodeTextContent(title),
className: (0, import_classnames.default)(bc, className, {
[`${bc}--stacked-${position}-of-${depth}`]: 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,
["is-visible"]: enablePresence,
[`${bc}--tearsheet-enable-presence`]: presenceContext?.autoEnablePresence
}),
containerClassName: (0, import_classnames.default)(`${bc}__container`, {
[`${bc}__container--lower`]: verticalPosition === "lower",
[`${bc}__container--mixed-size-stacking`]: !areAllSameSizeVariant()
}),
onClose,
open,
selectorPrimaryFocus,
onKeyDown: keyDownListener,
preventCloseOnClickOutside: !isPassive,
ref: mergedRefs,
selectorsFloatingMenus: [
`.${carbonPrefix}--overflow-menu-options`,
`.${carbonPrefix}--tooltip`,
".flatpickr-calendar",
`.${bc}__container`,
`.${carbonPrefix}--menu`,
...selectorsFloatingMenus
],
size: "sm",
"data-tearsheet-exiting": presenceContext?.isExiting || void 0
}, includeHeader && /* @__PURE__ */ React.createElement(ModalHeader, {
className: (0, import_classnames.default)(`${bc}__header`, {
[`${bc}__header--with-close-icon`]: effectiveHasCloseIcon,
[`${bc}__header--with-nav`]: navigation
}),
closeClassName: (0, import_classnames.default)(`${bc}__header--no-close-icon`)
}, headerActions && /* @__PURE__ */ React.createElement("div", { className: `${bc}__header-actions` }, headerActions), (decorator || deprecated_slug) && /* @__PURE__ */ React.createElement("div", { className: `${bc}__decorator` }, normalizedDecorator || deprecated_slug), effectiveHasCloseIcon && /* @__PURE__ */ React.createElement("div", { className: `${bc}__close-button ${carbonPrefix}--modal-close-button` }, /* @__PURE__ */ React.createElement(IconButton, {
className: `${carbonPrefix}--modal-close`,
label: closeIconDescription,
onClick: onClose,
align: "left"
}, /* @__PURE__ */ React.createElement(Close, {
size: 20,
"aria-hidden": "true",
tabIndex: "-1",
className: `${carbonPrefix}--modal-close__icon`
}))), /* @__PURE__ */ React.createElement(Wrap, {
className: `${bc}__header-content`,
element: wide ? Layer : void 0
}, /* @__PURE__ */ React.createElement(Wrap, { className: `${bc}__header-fields` }, /* @__PURE__ */ React.createElement(Wrap, { className: `${bcModalHeader}__label` }, label), /* @__PURE__ */ React.createElement(Wrap, {
element: "h3",
className: (0, import_classnames.default)(`${bcModalHeader}__heading`, `${bc}__heading`)
}, title), /* @__PURE__ */ React.createElement(Wrap, { className: `${bc}__header-description` }, description))), /* @__PURE__ */ React.createElement(Wrap, { className: `${bc}__header-navigation` }, navigation)), /* @__PURE__ */ React.createElement(Wrap, {
ref: modalBodyRef,
className: `${carbonPrefix}--modal-content ${bc}__body`
}, /* @__PURE__ */ React.createElement(Wrap, {
className: (0, import_classnames.default)({
[`${bc}__influencer`]: true,
[`${bc}__influencer--wide`]: influencerWidth === "wide"
}),
neverRender: influencerPosition === "right",
element: SectionLevel3
}, /* @__PURE__ */ React.createElement(Wrap, {
element: Layer,
className: `${bc}__layer`
}, influencer)), /* @__PURE__ */ React.createElement(Wrap, { className: `${bc}__right` }, /* @__PURE__ */ React.createElement(Wrap, {
className: `${bc}__main`,
alwaysRender: includeActions
}, /* @__PURE__ */ React.createElement(Wrap, {
className: `${bc}__content`,
alwaysRender: !!(influencer && influencerPosition === "right"),
element: SectionLevel3
}, wide ? children : /* @__PURE__ */ React.createElement(Wrap, {
element: Layer,
className: `${bc}__layer`
}, children)), /* @__PURE__ */ React.createElement(Wrap, {
className: (0, import_classnames.default)({
[`${bc}__influencer`]: true,
[`${bc}__influencer--wide`]: influencerWidth === "wide"
}),
neverRender: influencerPosition !== "right",
element: SectionLevel3
}, /* @__PURE__ */ React.createElement(Wrap, {
element: Layer,
className: `${bc}__layer`
}, influencer))), includeActions && /* @__PURE__ */ React.createElement(Wrap, { className: `${bc}__button-container` }, /* @__PURE__ */ React.createElement(ActionSet, {
actions,
buttonSize: wide ? "2xl" : void 0,
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;
}
});
TearsheetShell.displayName = componentName;
const portalType = typeof Element === "undefined" ? PropTypes.object : PropTypes.instanceOf(Element);
const deprecatedProps = {
/**
* @deprecated Property replaced by `decorator`
*/
slug: deprecateProp(PropTypes.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: PropTypes.oneOf(["normal", "lower"])
};
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: PropTypes.arrayOf(PropTypes.shape({
/**@ts-ignore*/
...Button.propTypes,
kind: PropTypes.oneOf([
"ghost",
"danger--ghost",
"secondary",
"danger",
"primary"
]),
label: PropTypes.string,
loading: PropTypes.bool,
/**@ts-ignore*/
onClick: Button.propTypes.onClick
})),
/**
* The main content of the tearsheet.
*/
children: PropTypes.node,
/**
* An optional class or classes to be added to the outermost element.
*/
className: PropTypes.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: PropTypes.string,
/**
* Optional prop that allows you to pass any component.
*/
decorator: PropTypes.oneOfType([PropTypes.node, PropTypes.bool]),
/**
* A description of the flow, displayed in the header area of the tearsheet.
*/
description: PropTypes.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: PropTypes.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: PropTypes.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: PropTypes.element,
/**
* The position of the influencer section, 'left' or 'right'.
*/
influencerPosition: PropTypes.oneOf(["left", "right"]),
/**
* The width of the influencer: 'narrow' (the default) is 256px, and 'wide'
* is 320px.
*/
influencerWidth: PropTypes.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: PropTypes.node,
/**
* Provide a ref to return focus to once the tearsheet is closed.
*/
/**@ts-ignore */
launcherButtonRef: PropTypes.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: PropTypes.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: PropTypes.func,
/**
* Specifies whether the tearsheet is currently open.
*/
open: PropTypes.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: PropTypes.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: PropTypes.arrayOf(PropTypes.string),
/**
* Specifies the width of the tearsheet, 'narrow' or 'wide'.
*/
/**@ts-ignore*/
size: PropTypes.oneOf(["narrow", "wide"]).isRequired,
/**
* The main title of the tearsheet, displayed in the header area.
*/
title: PropTypes.node,
...deprecatedProps
};
//#endregion
export { TearsheetShell, portalType, tearsheetShellWideProps };