@carbon/react
Version:
React components for the Carbon Design System
330 lines (328 loc) • 15.7 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 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.
*/
const require_runtime = require("../../_virtual/_rolldown/runtime.js");
const require_usePrefix = require("../../internal/usePrefix.js");
const require_keys = require("../../internal/keyboard/keys.js");
const require_match = require("../../internal/keyboard/match.js");
const require_useId = require("../../internal/useId.js");
const require_warning = require("../../internal/warning.js");
const require_deprecate = require("../../prop-types/deprecate.js");
const require_utils = require("../../internal/utils.js");
const require_index = require("../FeatureFlags/index.js");
const require_index$1 = require("../AILabel/index.js");
const require_useResizeObserver = require("../../internal/useResizeObserver.js");
const require_events = require("../../tools/events.js");
const require_mergeRefs = require("../../tools/mergeRefs.js");
const require_index$2 = require("../Layer/index.js");
const require_ModalHeader = require("./ModalHeader.js");
const require_ModalFooter = require("./ModalFooter.js");
const require_toggleClass = require("../../tools/toggleClass.js");
const require_requiredIfGivenPropIsTruthy = require("../../prop-types/requiredIfGivenPropIsTruthy.js");
const require_wrapFocus = require("../../internal/wrapFocus.js");
const require_Dialog = require("../Dialog/Dialog.js");
const require_useComposedModalState = require("./useComposedModalState.js");
const require_ComposedModalPresence = require("./ComposedModalPresence.js");
let classnames = require("classnames");
classnames = require_runtime.__toESM(classnames);
let react = require("react");
react = require_runtime.__toESM(react);
let prop_types = require("prop-types");
prop_types = require_runtime.__toESM(prop_types);
let react_jsx_runtime = require("react/jsx-runtime");
let _floating_ui_react = require("@floating-ui/react");
//#region src/components/ComposedModal/ComposedModal.tsx
/**
* Copyright IBM Corp. 2023, 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.
*/
const ModalBody = react.default.forwardRef(function ModalBody({ className: customClassName, children, hasForm, hasScrollingContent, ...rest }, ref) {
const prefix = require_usePrefix.usePrefix();
const contentRef = (0, react.useRef)(null);
const { height } = require_useResizeObserver.useResizeObserver({ ref: contentRef });
/**
* isScrollable is implicitly dependent on height, when height gets updated
* via `useResizeObserver`, clientHeight and scrollHeight get updated too
*/
const isScrollable = !!contentRef.current && contentRef?.current?.scrollHeight > contentRef?.current?.clientHeight;
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$2.Layer, {
className: (0, classnames.default)({
[`${prefix}--modal-content`]: true,
[`${prefix}--modal-content--with-form`]: hasForm,
[`${prefix}--modal-scroll-content`]: hasScrollingContent || isScrollable,
[`${prefix}--modal-scroll-content--no-fade`]: height <= 300
}, customClassName),
...hasScrollingContent || isScrollable ? {
tabIndex: 0,
role: "region"
} : {},
...rest,
ref: require_mergeRefs.mergeRefs(contentRef, ref),
children
});
});
ModalBody.propTypes = {
["aria-label"]: require_requiredIfGivenPropIsTruthy.requiredIfGivenPropIsTruthy("hasScrollingContent", prop_types.default.string),
children: prop_types.default.node,
className: prop_types.default.string,
hasForm: prop_types.default.bool,
hasScrollingContent: prop_types.default.bool
};
const ComposedModal = react.default.forwardRef(function ComposedModal({ open, ...props }, ref) {
const id = require_useId.useId();
const enablePresence = require_index.useFeatureFlag("enable-presence");
const hasPresenceContext = Boolean((0, react.useContext)(require_ComposedModalPresence.ComposedModalPresenceContext));
const hasPresenceOptIn = enablePresence || hasPresenceContext;
const exclusivePresenceContext = require_ComposedModalPresence.useExclusiveComposedModalPresenceContext(id);
if (hasPresenceOptIn && !exclusivePresenceContext) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_ComposedModalPresence.ComposedModalPresence, {
open: open ?? false,
_presenceId: id,
_autoEnablePresence: hasPresenceContext,
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ComposedModalDialog, {
open: true,
ref,
...props
})
});
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ComposedModalDialog, {
ref,
open,
...props
});
});
const ComposedModalDialog = react.default.forwardRef(function ComposedModalDialog({ ["aria-labelledby"]: ariaLabelledBy, ["aria-label"]: ariaLabel, children, className: customClassName, containerClassName, danger, decorator, isFullWidth, onClose, onKeyDown, open: externalOpen, preventCloseOnClickOutside, selectorPrimaryFocus = "[data-modal-primary-focus]", selectorsFloatingMenus, size, launcherButtonRef, slug, ...rest }, ref) {
const prefix = require_usePrefix.usePrefix();
const innerModal = (0, react.useRef)(null);
const button = (0, react.useRef)(null);
const startSentinel = (0, react.useRef)(null);
const endSentinel = (0, react.useRef)(null);
const onMouseDownTarget = (0, react.useRef)(null);
const presenceContext = (0, react.useContext)(require_ComposedModalPresence.ComposedModalPresenceContext);
const mergedRefs = (0, _floating_ui_react.useMergeRefs)([ref, presenceContext?.presenceRef]);
const enablePresence = require_index.useFeatureFlag("enable-presence") || presenceContext?.autoEnablePresence;
const open = externalOpen || enablePresence;
const modalState = require_useComposedModalState.useComposedModalState(open);
const [isOpen, setIsOpen] = presenceContext?.modalState ?? modalState;
const enableDialogElement = require_index.useFeatureFlag("enable-dialog-element");
const deprecatedFlag = require_index.useFeatureFlag("enable-experimental-focus-wrap-without-sentinels");
const focusTrapWithoutSentinelsFlag = require_index.useFeatureFlag("enable-focus-wrap-without-sentinels");
const focusTrapWithoutSentinels = deprecatedFlag || focusTrapWithoutSentinelsFlag;
require_warning.warning(!(focusTrapWithoutSentinels && enableDialogElement), "`<Modal>` detected both `focusTrapWithoutSentinels` and `enableDialogElement` feature flags are enabled. The native dialog element handles focus, so `enableDialogElement` must be off for `focusTrapWithoutSentinels` to have any effect.");
(0, react.useEffect)(() => {
if (!enableDialogElement) require_toggleClass.toggleClass(document.body, `${prefix}--body--with-modal-open`, !!open);
}, [open, prefix]);
(0, react.useEffect)(() => {
if (!enableDialogElement) return () => {
require_toggleClass.toggleClass(document.body, `${prefix}--body--with-modal-open`, false);
};
}, []);
function handleKeyDown(event) {
if (!enableDialogElement) {
if (focusTrapWithoutSentinels && open && require_match.match(event, require_keys.Tab) && innerModal.current) require_wrapFocus.wrapFocusWithoutSentinels({
containerNode: innerModal.current,
currentActiveNode: event.target,
event
});
}
onKeyDown?.(event);
}
function handleOnMouseDown(evt) {
onMouseDownTarget.current = evt.target;
}
function handleOnClick(evt) {
const { target } = evt;
const mouseDownTarget = onMouseDownTarget.current;
if ((isPassive && !preventCloseOnClickOutside || !isPassive && preventCloseOnClickOutside === false) && target instanceof Node && !require_wrapFocus.elementOrParentIsFloatingMenu(target, selectorsFloatingMenus, prefix) && innerModal.current && !innerModal.current.contains(target) && !innerModal.current.contains(mouseDownTarget)) closeModal(evt);
}
function handleBlur({ target: oldActiveNode, relatedTarget: currentActiveNode }) {
if (!enableDialogElement && !focusTrapWithoutSentinels && open && currentActiveNode && oldActiveNode && innerModal.current) {
const { current: bodyNode } = innerModal;
const { current: startSentinelNode } = startSentinel;
const { current: endSentinelNode } = endSentinel;
require_wrapFocus.wrapFocus({
bodyNode,
startTrapNode: startSentinelNode,
endTrapNode: endSentinelNode,
currentActiveNode,
oldActiveNode,
selectorsFloatingMenus: selectorsFloatingMenus?.filter(Boolean),
prefix
});
}
const modalContent = document.querySelector(`.${prefix}--modal-content`);
if (!modalContent || !modalContent.classList.contains(`${prefix}--modal-scroll-content`) || !currentActiveNode || !modalContent.contains(currentActiveNode)) return;
currentActiveNode.scrollIntoView({ block: "center" });
}
function closeModal(evt) {
if (!onClose || onClose(evt) !== false) setIsOpen(false);
}
const modalClass = (0, classnames.default)(`${prefix}--modal`, {
"is-visible": enablePresence || isOpen,
[`${prefix}--modal--enable-presence`]: presenceContext?.autoEnablePresence,
[`${prefix}--modal--danger`]: danger,
[`${prefix}--modal--slug`]: slug,
[`${prefix}--modal--decorator`]: decorator
}, customClassName);
const containerClass = (0, classnames.default)(`${prefix}--modal-container`, size && `${prefix}--modal-container--${size}`, isFullWidth && `${prefix}--modal-container--full-width`, containerClassName);
let generatedAriaLabel;
const childrenWithProps = react.default.Children.toArray(children).map((child) => {
if (require_utils.isComponentElement(child, require_ModalHeader.ModalHeader)) {
generatedAriaLabel = child.props.label;
return (0, react.cloneElement)(child, { closeModal });
}
if (require_utils.isComponentElement(child, require_ModalFooter.ModalFooter)) return (0, react.cloneElement)(child, {
closeModal,
inputref: button,
danger
});
return child;
});
const isPassive = !react.Children.toArray(childrenWithProps).some((child) => require_utils.isComponentElement(child, require_ModalFooter.ModalFooter));
require_warning.warning(!(!isPassive && preventCloseOnClickOutside === false), "`<ComposedModal>` prop `preventCloseOnClickOutside` should not be `false` when `<ModalFooter>` is present. Transactional, non-passive Modals should not be dissmissable by clicking outside. See: https://carbondesignsystem.com/components/modal/usage/#transactional-modal");
(0, react.useEffect)(() => {
if (!open) return;
const handleEscapeKey = (event) => {
if (require_match.match(event, require_keys.Escape)) {
event.preventDefault();
event.stopPropagation();
closeModal(event);
}
};
document.addEventListener("keydown", handleEscapeKey, true);
return () => {
document.removeEventListener("keydown", handleEscapeKey, true);
};
}, [open]);
(0, react.useEffect)(() => {
if (!enableDialogElement && !enablePresence && !open && launcherButtonRef) setTimeout(() => {
launcherButtonRef.current?.focus();
});
}, [
enableDialogElement,
enablePresence,
open,
launcherButtonRef
]);
(0, react.useEffect)(() => {
const launcherButton = launcherButtonRef?.current;
return () => {
if (enablePresence && launcherButton) setTimeout(() => {
launcherButton.focus();
});
};
}, [enablePresence, launcherButtonRef]);
(0, react.useEffect)(() => {
if (!enableDialogElement) {
const initialFocus = (focusContainerElement) => {
const containerElement = focusContainerElement || innerModal.current;
const primaryFocusElement = containerElement ? containerElement.querySelector(danger ? `.${prefix}--btn--secondary` : selectorPrimaryFocus) : null;
if (primaryFocusElement) return primaryFocusElement;
return button && button.current;
};
const focusButton = (focusContainerElement) => {
const target = initialFocus(focusContainerElement);
const closeButton = focusContainerElement.querySelector(`.${prefix}--modal-close`);
if (target) target.focus();
else if (!target && closeButton) closeButton?.focus();
};
if (open && isOpen) focusButton(innerModal.current);
}
}, [
open,
selectorPrimaryFocus,
isOpen
]);
const candidate = slug ?? decorator;
const normalizedDecorator = require_utils.isComponentElement(candidate, require_index$1.AILabel) ? (0, react.cloneElement)(candidate, { size: "sm" }) : candidate;
const modalBody = enableDialogElement ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_Dialog.Dialog, {
open,
focusAfterCloseRef: launcherButtonRef,
modal: true,
className: containerClass,
"aria-label": ariaLabel ? ariaLabel : generatedAriaLabel,
"aria-labelledby": ariaLabelledBy,
"data-exiting": presenceContext?.isExiting || void 0,
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
ref: innerModal,
className: `${prefix}--modal-container-body`,
children: [slug ? normalizedDecorator : decorator ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
className: `${prefix}--modal--inner__decorator`,
children: normalizedDecorator
}) : "", childrenWithProps]
})
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
className: containerClass,
role: "dialog",
"aria-modal": "true",
"aria-label": ariaLabel ? ariaLabel : generatedAriaLabel,
"aria-labelledby": ariaLabelledBy,
children: [
!focusTrapWithoutSentinels && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
type: "button",
ref: startSentinel,
className: `${prefix}--visually-hidden`,
children: "Focus sentinel"
}),
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
ref: innerModal,
className: `${prefix}--modal-container-body`,
children: [slug ? normalizedDecorator : decorator ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
className: `${prefix}--modal--inner__decorator`,
children: normalizedDecorator
}) : "", childrenWithProps]
}),
!focusTrapWithoutSentinels && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
type: "button",
ref: endSentinel,
className: `${prefix}--visually-hidden`,
children: "Focus sentinel"
})
]
});
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$2.Layer, {
...rest,
level: 0,
role: "presentation",
ref: mergedRefs,
"aria-hidden": !open,
onBlur: handleBlur,
onClick: require_events.composeEventHandlers([rest?.onClick, handleOnClick]),
onMouseDown: require_events.composeEventHandlers([rest?.onMouseDown, handleOnMouseDown]),
onKeyDown: handleKeyDown,
className: modalClass,
"data-exiting": presenceContext?.isExiting || void 0,
children: modalBody
});
});
ComposedModal.propTypes = {
["aria-label"]: prop_types.default.string,
["aria-labelledby"]: prop_types.default.string,
children: prop_types.default.node,
className: prop_types.default.string,
containerClassName: prop_types.default.string,
danger: prop_types.default.bool,
decorator: prop_types.default.node,
isFullWidth: prop_types.default.bool,
launcherButtonRef: prop_types.default.oneOfType([prop_types.default.func, prop_types.default.shape({ current: prop_types.default.any })]),
onClose: prop_types.default.func,
onKeyDown: prop_types.default.func,
open: prop_types.default.bool,
preventCloseOnClickOutside: prop_types.default.bool,
selectorPrimaryFocus: prop_types.default.string,
selectorsFloatingMenus: prop_types.default.arrayOf(prop_types.default.string.isRequired),
size: prop_types.default.oneOf([
"xs",
"sm",
"md",
"lg"
]),
slug: require_deprecate.deprecate(prop_types.default.node, "The `slug` prop for `ComposedModal` has been deprecated in favor of the new `decorator` prop. It will be removed in the next major release.")
};
//#endregion
exports.ModalBody = ModalBody;
exports.default = ComposedModal;