UNPKG

@carbon/ibm-products

Version:
474 lines (472 loc) 18.8 kB
/** * 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 };