@carbon/react
Version:
React components for the Carbon Design System
510 lines (508 loc) • 20.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.
*/
import { usePrefix } from "../../internal/usePrefix.js";
import { Text } from "../Text/Text.js";
import { Enter, Escape, Tab } from "../../internal/keyboard/keys.js";
import { match } from "../../internal/keyboard/match.js";
import { useId } from "../../internal/useId.js";
import { noopFn } from "../../internal/noopFn.js";
import { warning } from "../../internal/warning.js";
import { deprecate } from "../../prop-types/deprecate.js";
import { isComponentElement } from "../../internal/utils.js";
import { useMergedRefs } from "../../internal/useMergedRefs.js";
import { useFeatureFlag } from "../FeatureFlags/index.js";
import { IconButton } from "../IconButton/index.js";
import Button_default from "../Button/index.js";
import ButtonSet_default from "../ButtonSet/index.js";
import { AILabel } from "../AILabel/index.js";
import { useResizeObserver } from "../../internal/useResizeObserver.js";
import { composeEventHandlers } from "../../tools/events.js";
import { Layer } from "../Layer/index.js";
import InlineLoading_default from "../InlineLoading/index.js";
import { toggleClass } from "../../tools/toggleClass.js";
import { requiredIfGivenPropIsTruthy } from "../../prop-types/requiredIfGivenPropIsTruthy.js";
import { elementOrParentIsFloatingMenu, wrapFocus, wrapFocusWithoutSentinels } from "../../internal/wrapFocus.js";
import { Dialog } from "../Dialog/Dialog.js";
import { usePreviousValue } from "../../internal/usePreviousValue.js";
import { ModalPresence, ModalPresenceContext, useExclusiveModalPresenceContext } from "./ModalPresence.js";
import classNames from "classnames";
import React, { cloneElement, useContext, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { Close } from "@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.forwardRef(function Modal({ open, ...props }, ref) {
const id = useId();
const enablePresence = useFeatureFlag("enable-presence");
const hasPresenceContext = Boolean(useContext(ModalPresenceContext));
const hasPresenceOptIn = enablePresence || hasPresenceContext;
const exclusivePresenceContext = useExclusiveModalPresenceContext(id);
if (hasPresenceOptIn && !exclusivePresenceContext) return /* @__PURE__ */ jsx(ModalPresence, {
open: open ?? false,
_presenceId: id,
_autoEnablePresence: hasPresenceContext,
children: /* @__PURE__ */ jsx(ModalDialog, {
open: true,
ref,
...props
})
});
return /* @__PURE__ */ jsx(ModalDialog, {
ref,
open,
...props
});
});
const ModalDialog = React.forwardRef(function ModalDialog({ "aria-label": ariaLabelProp, children, className, decorator, modalHeading = "", modalLabel = "", modalAriaLabel, passiveModal = false, secondaryButtonText, primaryButtonText, open: externalOpen, onRequestClose = noopFn, onRequestSubmit = 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 = noopFn, slug, ...rest }, ref) {
const prefix = usePrefix();
const button = useRef(null);
const secondaryButton = useRef(null);
const contentRef = useRef(null);
const innerModal = useRef(null);
const startTrap = useRef(null);
const endTrap = useRef(null);
const wrapFocusTimeout = useRef(null);
const modalInstanceId = `modal-${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 = classNames({ [`${prefix}--btn--loading`]: loadingStatus !== "inactive" });
const loadingActive = loadingStatus !== "inactive";
const presenceContext = useContext(ModalPresenceContext);
const mergedRefs = useMergedRefs([ref, presenceContext?.presenceRef]);
const enablePresence = useFeatureFlag("enable-presence") || presenceContext?.autoEnablePresence;
const open = externalOpen || enablePresence;
const prevOpen = usePreviousValue(open);
const deprecatedFlag = useFeatureFlag("enable-experimental-focus-wrap-without-sentinels");
const focusTrapWithoutSentinels = useFeatureFlag("enable-focus-wrap-without-sentinels") || deprecatedFlag;
const enableDialogElement = useFeatureFlag("enable-dialog-element");
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.");
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 (match(evt, Enter) && shouldSubmitOnEnter && !isCloseButton(target) && document.activeElement !== button.current) onRequestSubmit(evt);
if (focusTrapWithoutSentinels && !enableDialogElement && match(evt, Tab) && innerModal.current) wrapFocusWithoutSentinels({
containerNode: innerModal.current,
currentActiveNode: target,
event: evt
});
}
}
function handleOnClick(evt) {
const { target } = evt;
if ((passiveModal && !preventCloseOnClickOutside || !passiveModal && preventCloseOnClickOutside === false) && target instanceof Node && !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(() => {
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 } = useResizeObserver({ ref: contentRef });
const modalClasses = classNames(`${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 = classNames(`${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 = classNames(`${prefix}--modal-content`, {
[`${prefix}--modal-scroll-content`]: hasScrollingContent || isScrollable,
[`${prefix}--modal-scroll-content--no-fade`]: height <= 300
});
const footerClasses = classNames(`${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;
}
useEffect(() => {
if (!open) return;
const handleEscapeKey = (event) => {
if (match(event, Escape)) {
event.preventDefault();
event.stopPropagation();
onRequestClose(event);
}
};
document.addEventListener("keydown", handleEscapeKey, true);
return () => {
document.removeEventListener("keydown", handleEscapeKey, true);
};
}, [open]);
useEffect(() => {
return () => {
if (!enableDialogElement) toggleClass(document.body, `${prefix}--body--with-modal-open`, false);
};
}, [prefix, enableDialogElement]);
useEffect(() => {
if (!enableDialogElement) toggleClass(document.body, `${prefix}--body--with-modal-open`, open ?? false);
}, [
open,
prefix,
enableDialogElement
]);
useEffect(() => {
if (!enableDialogElement && !enablePresence && prevOpen && !open && launcherButtonRef) setTimeout(() => {
if ("current" in launcherButtonRef) launcherButtonRef.current?.focus();
});
}, [
open,
prevOpen,
launcherButtonRef,
enableDialogElement,
enablePresence
]);
useEffect(() => {
const launcherButton = launcherButtonRef?.current;
return () => {
if (enablePresence && launcherButton) setTimeout(() => {
launcherButton.focus();
});
};
}, [enablePresence, launcherButtonRef]);
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 = isComponentElement(candidate, AILabel) ? cloneElement(candidate, { size: "sm" }) : candidate;
const modalButton = /* @__PURE__ */ jsx("div", {
className: `${prefix}--modal-close-button`,
children: /* @__PURE__ */ jsx(IconButton, {
className: modalCloseButtonClass,
label: closeButtonLabel,
onClick: onRequestClose,
"aria-label": closeButtonLabel,
align: "left",
ref: button,
children: /* @__PURE__ */ jsx(Close, {
size: 20,
"aria-hidden": "true",
tabIndex: "-1",
className: `${modalCloseButtonClass}__icon`
})
})
});
const isAlertDialog = alert && !passiveModal;
const modalBody = enableDialogElement ? /* @__PURE__ */ jsxs(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__ */ jsxs("div", {
className: `${prefix}--modal-header`,
children: [
modalLabel && /* @__PURE__ */ jsx(Text, {
as: "h2",
id: modalLabelId,
className: `${prefix}--modal-header__label`,
children: modalLabel
}),
/* @__PURE__ */ jsx(Text, {
as: "h2",
id: modalHeadingId,
className: `${prefix}--modal-header__heading`,
children: modalHeading
}),
decorator ? /* @__PURE__ */ jsx("div", {
className: `${prefix}--modal--inner__decorator`,
children: normalizedDecorator
}) : "",
/* @__PURE__ */ jsx("div", {
className: `${prefix}--modal-close-button`,
children: /* @__PURE__ */ jsx(IconButton, {
className: modalCloseButtonClass,
label: closeButtonLabel,
onClick: onRequestClose,
"aria-label": closeButtonLabel,
align: "left",
ref: button,
children: /* @__PURE__ */ jsx(Close, {
size: 20,
"aria-hidden": "true",
tabIndex: "-1",
className: `${modalCloseButtonClass}__icon`
})
})
})
]
}),
/* @__PURE__ */ jsx(Layer, {
ref: contentRef,
id: modalBodyId,
className: contentClasses,
...hasScrollingContentProps,
children
}),
!passiveModal && /* @__PURE__ */ jsxs(ButtonSet_default, {
className: footerClasses,
"aria-busy": loadingActive,
children: [Array.isArray(secondaryButtons) && secondaryButtons.length <= 2 ? secondaryButtons.map(({ buttonText, onClick: onButtonClick }, i) => /* @__PURE__ */ jsx(Button_default, {
kind: "secondary",
onClick: onButtonClick,
children: buttonText
}, `${buttonText}-${i}`)) : secondaryButtonText && /* @__PURE__ */ jsx(Button_default, {
disabled: loadingActive,
kind: "secondary",
onClick: onSecondaryButtonClick,
ref: secondaryButton,
children: secondaryButtonText
}), /* @__PURE__ */ jsx(Button_default, {
className: primaryButtonClass,
kind: danger ? "danger" : "primary",
disabled: loadingActive || primaryButtonDisabled,
onClick: onRequestSubmit,
ref: button,
children: loadingStatus === "inactive" ? primaryButtonText : /* @__PURE__ */ jsx(InlineLoading_default, {
status: loadingStatus,
description: loadingDescription,
iconDescription: loadingIconDescription,
className: `${prefix}--inline-loading--btn`,
onSuccess: onLoadingSuccess
})
})]
})
]
}) : /* @__PURE__ */ jsxs(Fragment, { children: [
!enableDialogElement && !focusTrapWithoutSentinels && /* @__PURE__ */ jsx("span", {
ref: startTrap,
tabIndex: 0,
role: "link",
className: `${prefix}--visually-hidden`,
children: "Focus sentinel"
}),
/* @__PURE__ */ jsxs("div", {
ref: innerModal,
role: "dialog",
...alertDialogProps,
className: containerClasses,
"aria-label": ariaLabel,
"aria-modal": "true",
tabIndex: -1,
children: [
/* @__PURE__ */ jsxs("div", {
className: `${prefix}--modal-header`,
children: [
passiveModal && modalButton,
modalLabel && /* @__PURE__ */ jsx(Text, {
as: "h2",
id: modalLabelId,
className: `${prefix}--modal-header__label`,
children: modalLabel
}),
/* @__PURE__ */ jsx(Text, {
as: "h2",
id: modalHeadingId,
className: `${prefix}--modal-header__heading`,
children: modalHeading
}),
slug ? normalizedDecorator : decorator ? /* @__PURE__ */ jsx("div", {
className: `${prefix}--modal--inner__decorator`,
children: normalizedDecorator
}) : "",
!passiveModal && modalButton
]
}),
/* @__PURE__ */ jsx(Layer, {
ref: contentRef,
id: modalBodyId,
className: contentClasses,
...hasScrollingContentProps,
children
}),
!passiveModal && /* @__PURE__ */ jsxs(ButtonSet_default, {
className: footerClasses,
"aria-busy": loadingActive,
children: [Array.isArray(secondaryButtons) && secondaryButtons.length <= 2 ? secondaryButtons.map(({ buttonText, onClick: onButtonClick }, i) => /* @__PURE__ */ jsx(Button_default, {
kind: "secondary",
onClick: onButtonClick,
children: buttonText
}, `${buttonText}-${i}`)) : secondaryButtonText && /* @__PURE__ */ jsx(Button_default, {
disabled: loadingActive,
kind: "secondary",
onClick: onSecondaryButtonClick,
ref: secondaryButton,
children: secondaryButtonText
}), /* @__PURE__ */ jsx(Button_default, {
className: primaryButtonClass,
kind: danger ? "danger" : "primary",
disabled: loadingActive || primaryButtonDisabled,
onClick: onRequestSubmit,
ref: button,
children: loadingStatus === "inactive" ? primaryButtonText : /* @__PURE__ */ jsx(InlineLoading_default, {
status: loadingStatus,
description: loadingDescription,
iconDescription: loadingIconDescription,
className: `${prefix}--inline-loading--btn`,
onSuccess: onLoadingSuccess
})
})]
})
]
}),
!enableDialogElement && !focusTrapWithoutSentinels && /* @__PURE__ */ jsx("span", {
ref: endTrap,
tabIndex: 0,
role: "link",
className: `${prefix}--visually-hidden`,
children: "Focus sentinel"
})
] });
return /* @__PURE__ */ jsx(Layer, {
...rest,
level: 0,
onKeyDown: handleKeyDown,
onClick: composeEventHandlers([rest?.onClick, handleOnClick]),
onBlur: handleBlur,
className: modalClasses,
role: "presentation",
ref: mergedRefs,
"data-exiting": presenceContext?.isExiting || void 0,
children: modalBody
});
});
Modal.propTypes = {
alert: PropTypes.bool,
["aria-label"]: requiredIfGivenPropIsTruthy("hasScrollingContent", PropTypes.string),
children: PropTypes.node,
className: PropTypes.string,
closeButtonLabel: PropTypes.string,
danger: PropTypes.bool,
decorator: PropTypes.node,
hasScrollingContent: PropTypes.bool,
id: PropTypes.string,
isFullWidth: PropTypes.bool,
launcherButtonRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.oneOfType([typeof HTMLButtonElement !== "undefined" ? PropTypes.instanceOf(HTMLButtonElement) : PropTypes.any, PropTypes.oneOf([null])]).isRequired })]),
loadingDescription: PropTypes.string,
loadingIconDescription: PropTypes.string,
loadingStatus: PropTypes.oneOf([
"inactive",
"active",
"finished",
"error"
]),
modalAriaLabel: PropTypes.string,
modalHeading: PropTypes.node,
modalLabel: PropTypes.node,
onKeyDown: PropTypes.func,
onLoadingSuccess: PropTypes.func,
onRequestClose: PropTypes.func,
onRequestSubmit: PropTypes.func,
onSecondarySubmit: PropTypes.func,
open: PropTypes.bool,
passiveModal: PropTypes.bool,
preventCloseOnClickOutside: (props, propName) => {
if (!props.passiveModal && props[propName] === false) return new Error(invalidOutsideClickMessage);
return null;
},
primaryButtonDisabled: PropTypes.bool,
primaryButtonText: PropTypes.node,
secondaryButtonText: PropTypes.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: PropTypes.node,
onClick: PropTypes.func
};
props[propName].forEach((secondaryButton) => {
PropTypes.checkPropTypes(shape, secondaryButton, propName, componentName);
});
}
return null;
},
selectorPrimaryFocus: PropTypes.string,
selectorsFloatingMenus: PropTypes.arrayOf(PropTypes.string.isRequired),
shouldSubmitOnEnter: PropTypes.bool,
size: PropTypes.oneOf(ModalSizes),
slug: deprecate(PropTypes.node, "The `slug` prop has been deprecated and will be removed in the next major version. Use the decorator prop instead.")
};
//#endregion
export { Modal as default };