@carbon/react
Version:
React components for the Carbon Design System
514 lines (512 loc) • 23.3 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_Text = require("../Text/Text.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_noopFn = require("../../internal/noopFn.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_useMergedRefs = require("../../internal/useMergedRefs.js");
const require_index = require("../FeatureFlags/index.js");
const require_index$1 = require("../IconButton/index.js");
const require_index$2 = require("../Button/index.js");
const require_index$3 = require("../ButtonSet/index.js");
const require_index$4 = require("../AILabel/index.js");
const require_useResizeObserver = require("../../internal/useResizeObserver.js");
const require_events = require("../../tools/events.js");
const require_index$5 = require("../Layer/index.js");
const require_index$6 = require("../InlineLoading/index.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_usePreviousValue = require("../../internal/usePreviousValue.js");
const require_ModalPresence = require("./ModalPresence.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 _carbon_icons_react = require("@carbon/icons-react");
//#region src/components/Modal/Modal.tsx
/**
* Copyright IBM Corp. 2016, 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.
*/
const ModalSizes = [
"xs",
"sm",
"md",
"lg"
];
const invalidOutsideClickMessage = "`<Modal>` prop `preventCloseOnClickOutside` should not be `false` when `passiveModal` is `false`. Transactional, non-passive Modals should not be dissmissable by clicking outside. See: https://carbondesignsystem.com/components/modal/usage/#transactional-modal";
const Modal = react.default.forwardRef(function Modal({ open, ...props }, ref) {
const id = require_useId.useId();
const enablePresence = require_index.useFeatureFlag("enable-presence");
const hasPresenceContext = Boolean((0, react.useContext)(require_ModalPresence.ModalPresenceContext));
const hasPresenceOptIn = enablePresence || hasPresenceContext;
const exclusivePresenceContext = require_ModalPresence.useExclusiveModalPresenceContext(id);
if (hasPresenceOptIn && !exclusivePresenceContext) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_ModalPresence.ModalPresence, {
open: open ?? false,
_presenceId: id,
_autoEnablePresence: hasPresenceContext,
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModalDialog, {
open: true,
ref,
...props
})
});
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModalDialog, {
ref,
open,
...props
});
});
const ModalDialog = react.default.forwardRef(function ModalDialog({ "aria-label": ariaLabelProp, children, className, decorator, modalHeading = "", modalLabel = "", modalAriaLabel, passiveModal = false, secondaryButtonText, primaryButtonText, open: externalOpen, onRequestClose = require_noopFn.noopFn, onRequestSubmit = require_noopFn.noopFn, onSecondarySubmit, primaryButtonDisabled = false, danger, alert, secondaryButtons, selectorPrimaryFocus = "[data-modal-primary-focus]", selectorsFloatingMenus, shouldSubmitOnEnter, size, hasScrollingContent = false, closeButtonLabel = "Close", preventCloseOnClickOutside, isFullWidth, launcherButtonRef, loadingStatus = "inactive", loadingDescription, loadingIconDescription, onLoadingSuccess = require_noopFn.noopFn, slug, ...rest }, ref) {
const prefix = require_usePrefix.usePrefix();
const button = (0, react.useRef)(null);
const secondaryButton = (0, react.useRef)(null);
const contentRef = (0, react.useRef)(null);
const innerModal = (0, react.useRef)(null);
const startTrap = (0, react.useRef)(null);
const endTrap = (0, react.useRef)(null);
const wrapFocusTimeout = (0, react.useRef)(null);
const modalInstanceId = `modal-${require_useId.useId()}`;
const modalLabelId = `${prefix}--modal-header__label--${modalInstanceId}`;
const modalHeadingId = `${prefix}--modal-header__heading--${modalInstanceId}`;
const modalBodyId = `${prefix}--modal-body--${modalInstanceId}`;
const modalCloseButtonClass = `${prefix}--modal-close`;
const primaryButtonClass = (0, classnames.default)({ [`${prefix}--btn--loading`]: loadingStatus !== "inactive" });
const loadingActive = loadingStatus !== "inactive";
const presenceContext = (0, react.useContext)(require_ModalPresence.ModalPresenceContext);
const mergedRefs = require_useMergedRefs.useMergedRefs([ref, presenceContext?.presenceRef]);
const enablePresence = require_index.useFeatureFlag("enable-presence") || presenceContext?.autoEnablePresence;
const open = externalOpen || enablePresence;
const prevOpen = require_usePreviousValue.usePreviousValue(open);
const deprecatedFlag = require_index.useFeatureFlag("enable-experimental-focus-wrap-without-sentinels");
const focusTrapWithoutSentinels = require_index.useFeatureFlag("enable-focus-wrap-without-sentinels") || deprecatedFlag;
const enableDialogElement = require_index.useFeatureFlag("enable-dialog-element");
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.");
require_warning.warning(!(!passiveModal && preventCloseOnClickOutside === false), invalidOutsideClickMessage);
function isCloseButton(element) {
return !onSecondarySubmit && element === secondaryButton.current || element.classList.contains(modalCloseButtonClass);
}
function handleKeyDown(evt) {
const { target } = evt;
evt.stopPropagation();
if (open && target instanceof HTMLElement) {
if (require_match.match(evt, require_keys.Enter) && shouldSubmitOnEnter && !isCloseButton(target) && document.activeElement !== button.current) onRequestSubmit(evt);
if (focusTrapWithoutSentinels && !enableDialogElement && require_match.match(evt, require_keys.Tab) && innerModal.current) require_wrapFocus.wrapFocusWithoutSentinels({
containerNode: innerModal.current,
currentActiveNode: target,
event: evt
});
}
}
function handleOnClick(evt) {
const { target } = evt;
if ((passiveModal && !preventCloseOnClickOutside || !passiveModal && preventCloseOnClickOutside === false) && target instanceof Node && !require_wrapFocus.elementOrParentIsFloatingMenu(target, selectorsFloatingMenus, prefix) && innerModal.current && !innerModal.current.contains(target)) onRequestClose(evt);
}
function handleBlur({ target: oldActiveNode, relatedTarget: currentActiveNode }) {
if (!enableDialogElement && open && oldActiveNode instanceof HTMLElement && currentActiveNode instanceof HTMLElement) {
const { current: bodyNode } = innerModal;
const { current: startTrapNode } = startTrap;
const { current: endTrapNode } = endTrap;
wrapFocusTimeout.current = setTimeout(() => {
require_wrapFocus.wrapFocus({
bodyNode,
startTrapNode,
endTrapNode,
currentActiveNode,
oldActiveNode,
selectorsFloatingMenus,
prefix
});
if (wrapFocusTimeout.current) clearTimeout(wrapFocusTimeout.current);
});
}
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" });
}
const onSecondaryButtonClick = onSecondarySubmit ? onSecondarySubmit : onRequestClose;
const { height } = require_useResizeObserver.useResizeObserver({ ref: contentRef });
const modalClasses = (0, classnames.default)(`${prefix}--modal`, {
[`${prefix}--modal-tall`]: !passiveModal,
"is-visible": enablePresence || open,
[`${prefix}--modal--enable-presence`]: presenceContext?.autoEnablePresence,
[`${prefix}--modal--danger`]: danger,
[`${prefix}--modal--slug`]: slug,
[`${prefix}--modal--decorator`]: decorator
}, className);
const containerClasses = (0, classnames.default)(`${prefix}--modal-container`, {
[`${prefix}--modal-container--${size}`]: size,
[`${prefix}--modal-container--full-width`]: isFullWidth
});
/**
* 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;
const contentClasses = (0, classnames.default)(`${prefix}--modal-content`, {
[`${prefix}--modal-scroll-content`]: hasScrollingContent || isScrollable,
[`${prefix}--modal-scroll-content--no-fade`]: height <= 300
});
const footerClasses = (0, classnames.default)(`${prefix}--modal-footer`, { [`${prefix}--modal-footer--three-button`]: Array.isArray(secondaryButtons) && secondaryButtons.length === 2 });
const asStringOrUndefined = (node) => {
return typeof node === "string" ? node : void 0;
};
const modalLabelStr = asStringOrUndefined(modalLabel);
const modalHeadingStr = asStringOrUndefined(modalHeading);
const ariaLabel = modalLabelStr || ariaLabelProp || modalAriaLabel || modalHeadingStr;
const hasScrollingContentProps = hasScrollingContent || isScrollable ? {
tabIndex: 0,
role: "region",
"aria-label": ariaLabel,
"aria-labelledby": modalLabel ? modalLabelId : modalHeadingId
} : {};
const alertDialogProps = {};
if (alert && passiveModal) alertDialogProps.role = "alert";
if (alert && !passiveModal) {
alertDialogProps.role = "alertdialog";
alertDialogProps["aria-describedby"] = modalBodyId;
}
(0, react.useEffect)(() => {
if (!open) return;
const handleEscapeKey = (event) => {
if (require_match.match(event, require_keys.Escape)) {
event.preventDefault();
event.stopPropagation();
onRequestClose(event);
}
};
document.addEventListener("keydown", handleEscapeKey, true);
return () => {
document.removeEventListener("keydown", handleEscapeKey, true);
};
}, [open]);
(0, react.useEffect)(() => {
return () => {
if (!enableDialogElement) require_toggleClass.toggleClass(document.body, `${prefix}--body--with-modal-open`, false);
};
}, [prefix, enableDialogElement]);
(0, react.useEffect)(() => {
if (!enableDialogElement) require_toggleClass.toggleClass(document.body, `${prefix}--body--with-modal-open`, open ?? false);
}, [
open,
prefix,
enableDialogElement
]);
(0, react.useEffect)(() => {
if (!enableDialogElement && !enablePresence && prevOpen && !open && launcherButtonRef) setTimeout(() => {
if ("current" in launcherButtonRef) launcherButtonRef.current?.focus();
});
}, [
open,
prevOpen,
launcherButtonRef,
enableDialogElement,
enablePresence
]);
(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(selectorPrimaryFocus) || danger && containerElement.querySelector(`.${prefix}--btn--secondary`));
if (primaryFocusElement) return primaryFocusElement;
return button && button.current;
};
const focusButton = (focusContainerElement) => {
const target = initialFocus(focusContainerElement);
if (target !== null) target.focus();
};
if (open) focusButton(innerModal.current);
}
}, [
open,
selectorPrimaryFocus,
danger,
prefix,
enableDialogElement
]);
const candidate = slug ?? decorator;
const normalizedDecorator = require_utils.isComponentElement(candidate, require_index$4.AILabel) ? (0, react.cloneElement)(candidate, { size: "sm" }) : candidate;
const modalButton = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
className: `${prefix}--modal-close-button`,
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$1.IconButton, {
className: modalCloseButtonClass,
label: closeButtonLabel,
onClick: onRequestClose,
"aria-label": closeButtonLabel,
align: "left",
ref: button,
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_carbon_icons_react.Close, {
size: 20,
"aria-hidden": "true",
tabIndex: "-1",
className: `${modalCloseButtonClass}__icon`
})
})
});
const isAlertDialog = alert && !passiveModal;
const modalBody = enableDialogElement ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(require_Dialog.Dialog, {
open,
focusAfterCloseRef: launcherButtonRef,
modal: true,
ref: innerModal,
role: isAlertDialog ? "alertdialog" : "",
"aria-describedby": isAlertDialog ? modalBodyId : "",
className: containerClasses,
"aria-label": ariaLabel,
"data-exiting": presenceContext?.isExiting || void 0,
children: [
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
className: `${prefix}--modal-header`,
children: [
modalLabel && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_Text.Text, {
as: "h2",
id: modalLabelId,
className: `${prefix}--modal-header__label`,
children: modalLabel
}),
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_Text.Text, {
as: "h2",
id: modalHeadingId,
className: `${prefix}--modal-header__heading`,
children: modalHeading
}),
decorator ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
className: `${prefix}--modal--inner__decorator`,
children: normalizedDecorator
}) : "",
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
className: `${prefix}--modal-close-button`,
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$1.IconButton, {
className: modalCloseButtonClass,
label: closeButtonLabel,
onClick: onRequestClose,
"aria-label": closeButtonLabel,
align: "left",
ref: button,
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_carbon_icons_react.Close, {
size: 20,
"aria-hidden": "true",
tabIndex: "-1",
className: `${modalCloseButtonClass}__icon`
})
})
})
]
}),
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$5.Layer, {
ref: contentRef,
id: modalBodyId,
className: contentClasses,
...hasScrollingContentProps,
children
}),
!passiveModal && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(require_index$3.default, {
className: footerClasses,
"aria-busy": loadingActive,
children: [Array.isArray(secondaryButtons) && secondaryButtons.length <= 2 ? secondaryButtons.map(({ buttonText, onClick: onButtonClick }, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$2.default, {
kind: "secondary",
onClick: onButtonClick,
children: buttonText
}, `${buttonText}-${i}`)) : secondaryButtonText && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$2.default, {
disabled: loadingActive,
kind: "secondary",
onClick: onSecondaryButtonClick,
ref: secondaryButton,
children: secondaryButtonText
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$2.default, {
className: primaryButtonClass,
kind: danger ? "danger" : "primary",
disabled: loadingActive || primaryButtonDisabled,
onClick: onRequestSubmit,
ref: button,
children: loadingStatus === "inactive" ? primaryButtonText : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$6.default, {
status: loadingStatus,
description: loadingDescription,
iconDescription: loadingIconDescription,
className: `${prefix}--inline-loading--btn`,
onSuccess: onLoadingSuccess
})
})]
})
]
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
!enableDialogElement && !focusTrapWithoutSentinels && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
ref: startTrap,
tabIndex: 0,
role: "link",
className: `${prefix}--visually-hidden`,
children: "Focus sentinel"
}),
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
ref: innerModal,
role: "dialog",
...alertDialogProps,
className: containerClasses,
"aria-label": ariaLabel,
"aria-modal": "true",
tabIndex: -1,
children: [
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
className: `${prefix}--modal-header`,
children: [
passiveModal && modalButton,
modalLabel && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_Text.Text, {
as: "h2",
id: modalLabelId,
className: `${prefix}--modal-header__label`,
children: modalLabel
}),
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_Text.Text, {
as: "h2",
id: modalHeadingId,
className: `${prefix}--modal-header__heading`,
children: modalHeading
}),
slug ? normalizedDecorator : decorator ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
className: `${prefix}--modal--inner__decorator`,
children: normalizedDecorator
}) : "",
!passiveModal && modalButton
]
}),
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$5.Layer, {
ref: contentRef,
id: modalBodyId,
className: contentClasses,
...hasScrollingContentProps,
children
}),
!passiveModal && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(require_index$3.default, {
className: footerClasses,
"aria-busy": loadingActive,
children: [Array.isArray(secondaryButtons) && secondaryButtons.length <= 2 ? secondaryButtons.map(({ buttonText, onClick: onButtonClick }, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$2.default, {
kind: "secondary",
onClick: onButtonClick,
children: buttonText
}, `${buttonText}-${i}`)) : secondaryButtonText && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$2.default, {
disabled: loadingActive,
kind: "secondary",
onClick: onSecondaryButtonClick,
ref: secondaryButton,
children: secondaryButtonText
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$2.default, {
className: primaryButtonClass,
kind: danger ? "danger" : "primary",
disabled: loadingActive || primaryButtonDisabled,
onClick: onRequestSubmit,
ref: button,
children: loadingStatus === "inactive" ? primaryButtonText : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$6.default, {
status: loadingStatus,
description: loadingDescription,
iconDescription: loadingIconDescription,
className: `${prefix}--inline-loading--btn`,
onSuccess: onLoadingSuccess
})
})]
})
]
}),
!enableDialogElement && !focusTrapWithoutSentinels && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
ref: endTrap,
tabIndex: 0,
role: "link",
className: `${prefix}--visually-hidden`,
children: "Focus sentinel"
})
] });
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$5.Layer, {
...rest,
level: 0,
onKeyDown: handleKeyDown,
onClick: require_events.composeEventHandlers([rest?.onClick, handleOnClick]),
onBlur: handleBlur,
className: modalClasses,
role: "presentation",
ref: mergedRefs,
"data-exiting": presenceContext?.isExiting || void 0,
children: modalBody
});
});
Modal.propTypes = {
alert: prop_types.default.bool,
["aria-label"]: require_requiredIfGivenPropIsTruthy.requiredIfGivenPropIsTruthy("hasScrollingContent", prop_types.default.string),
children: prop_types.default.node,
className: prop_types.default.string,
closeButtonLabel: prop_types.default.string,
danger: prop_types.default.bool,
decorator: prop_types.default.node,
hasScrollingContent: prop_types.default.bool,
id: prop_types.default.string,
isFullWidth: prop_types.default.bool,
launcherButtonRef: prop_types.default.oneOfType([prop_types.default.func, prop_types.default.shape({ current: prop_types.default.oneOfType([typeof HTMLButtonElement !== "undefined" ? prop_types.default.instanceOf(HTMLButtonElement) : prop_types.default.any, prop_types.default.oneOf([null])]).isRequired })]),
loadingDescription: prop_types.default.string,
loadingIconDescription: prop_types.default.string,
loadingStatus: prop_types.default.oneOf([
"inactive",
"active",
"finished",
"error"
]),
modalAriaLabel: prop_types.default.string,
modalHeading: prop_types.default.node,
modalLabel: prop_types.default.node,
onKeyDown: prop_types.default.func,
onLoadingSuccess: prop_types.default.func,
onRequestClose: prop_types.default.func,
onRequestSubmit: prop_types.default.func,
onSecondarySubmit: prop_types.default.func,
open: prop_types.default.bool,
passiveModal: prop_types.default.bool,
preventCloseOnClickOutside: (props, propName) => {
if (!props.passiveModal && props[propName] === false) return new Error(invalidOutsideClickMessage);
return null;
},
primaryButtonDisabled: prop_types.default.bool,
primaryButtonText: prop_types.default.node,
secondaryButtonText: prop_types.default.node,
secondaryButtons: (props, propName, componentName) => {
if (props.secondaryButtons) {
if (!Array.isArray(props.secondaryButtons) || props.secondaryButtons.length !== 2) return /* @__PURE__ */ new Error(`${propName} needs to be an array of two button config objects`);
const shape = {
buttonText: prop_types.default.node,
onClick: prop_types.default.func
};
props[propName].forEach((secondaryButton) => {
prop_types.default.checkPropTypes(shape, secondaryButton, propName, componentName);
});
}
return null;
},
selectorPrimaryFocus: prop_types.default.string,
selectorsFloatingMenus: prop_types.default.arrayOf(prop_types.default.string.isRequired),
shouldSubmitOnEnter: prop_types.default.bool,
size: prop_types.default.oneOf(ModalSizes),
slug: require_deprecate.deprecate(prop_types.default.node, "The `slug` prop has been deprecated and will be removed in the next major version. Use the decorator prop instead.")
};
//#endregion
exports.default = Modal;