@carbon/react
Version:
React components for the Carbon Design System
325 lines (323 loc) • 13.9 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 { Escape, Tab } from "../../internal/keyboard/keys.js";
import { match } from "../../internal/keyboard/match.js";
import { useId } from "../../internal/useId.js";
import { warning } from "../../internal/warning.js";
import { deprecate } from "../../prop-types/deprecate.js";
import { isComponentElement } from "../../internal/utils.js";
import { useFeatureFlag } from "../FeatureFlags/index.js";
import { AILabel } from "../AILabel/index.js";
import { useResizeObserver } from "../../internal/useResizeObserver.js";
import { composeEventHandlers } from "../../tools/events.js";
import { mergeRefs } from "../../tools/mergeRefs.js";
import { Layer } from "../Layer/index.js";
import { ModalHeader } from "./ModalHeader.js";
import { ModalFooter } from "./ModalFooter.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 { useComposedModalState } from "./useComposedModalState.js";
import { ComposedModalPresence, ComposedModalPresenceContext, useExclusiveComposedModalPresenceContext } from "./ComposedModalPresence.js";
import classNames from "classnames";
import React, { Children, cloneElement, useContext, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { jsx, jsxs } from "react/jsx-runtime";
import { useMergeRefs } from "@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.forwardRef(function ModalBody({ className: customClassName, children, hasForm, hasScrollingContent, ...rest }, ref) {
const prefix = usePrefix();
const contentRef = useRef(null);
const { height } = 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__ */ jsx(Layer, {
className: classNames({
[`${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: mergeRefs(contentRef, ref),
children
});
});
ModalBody.propTypes = {
["aria-label"]: requiredIfGivenPropIsTruthy("hasScrollingContent", PropTypes.string),
children: PropTypes.node,
className: PropTypes.string,
hasForm: PropTypes.bool,
hasScrollingContent: PropTypes.bool
};
const ComposedModal = React.forwardRef(function ComposedModal({ open, ...props }, ref) {
const id = useId();
const enablePresence = useFeatureFlag("enable-presence");
const hasPresenceContext = Boolean(useContext(ComposedModalPresenceContext));
const hasPresenceOptIn = enablePresence || hasPresenceContext;
const exclusivePresenceContext = useExclusiveComposedModalPresenceContext(id);
if (hasPresenceOptIn && !exclusivePresenceContext) return /* @__PURE__ */ jsx(ComposedModalPresence, {
open: open ?? false,
_presenceId: id,
_autoEnablePresence: hasPresenceContext,
children: /* @__PURE__ */ jsx(ComposedModalDialog, {
open: true,
ref,
...props
})
});
return /* @__PURE__ */ jsx(ComposedModalDialog, {
ref,
open,
...props
});
});
const ComposedModalDialog = React.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 = usePrefix();
const innerModal = useRef(null);
const button = useRef(null);
const startSentinel = useRef(null);
const endSentinel = useRef(null);
const onMouseDownTarget = useRef(null);
const presenceContext = useContext(ComposedModalPresenceContext);
const mergedRefs = useMergeRefs([ref, presenceContext?.presenceRef]);
const enablePresence = useFeatureFlag("enable-presence") || presenceContext?.autoEnablePresence;
const open = externalOpen || enablePresence;
const modalState = useComposedModalState(open);
const [isOpen, setIsOpen] = presenceContext?.modalState ?? modalState;
const enableDialogElement = useFeatureFlag("enable-dialog-element");
const deprecatedFlag = useFeatureFlag("enable-experimental-focus-wrap-without-sentinels");
const focusTrapWithoutSentinelsFlag = useFeatureFlag("enable-focus-wrap-without-sentinels");
const focusTrapWithoutSentinels = deprecatedFlag || focusTrapWithoutSentinelsFlag;
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.");
useEffect(() => {
if (!enableDialogElement) toggleClass(document.body, `${prefix}--body--with-modal-open`, !!open);
}, [open, prefix]);
useEffect(() => {
if (!enableDialogElement) return () => {
toggleClass(document.body, `${prefix}--body--with-modal-open`, false);
};
}, []);
function handleKeyDown(event) {
if (!enableDialogElement) {
if (focusTrapWithoutSentinels && open && match(event, Tab) && innerModal.current) 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 && !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;
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 = classNames(`${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 = classNames(`${prefix}--modal-container`, size && `${prefix}--modal-container--${size}`, isFullWidth && `${prefix}--modal-container--full-width`, containerClassName);
let generatedAriaLabel;
const childrenWithProps = React.Children.toArray(children).map((child) => {
if (isComponentElement(child, ModalHeader)) {
generatedAriaLabel = child.props.label;
return cloneElement(child, { closeModal });
}
if (isComponentElement(child, ModalFooter)) return cloneElement(child, {
closeModal,
inputref: button,
danger
});
return child;
});
const isPassive = !Children.toArray(childrenWithProps).some((child) => isComponentElement(child, ModalFooter));
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");
useEffect(() => {
if (!open) return;
const handleEscapeKey = (event) => {
if (match(event, Escape)) {
event.preventDefault();
event.stopPropagation();
closeModal(event);
}
};
document.addEventListener("keydown", handleEscapeKey, true);
return () => {
document.removeEventListener("keydown", handleEscapeKey, true);
};
}, [open]);
useEffect(() => {
if (!enableDialogElement && !enablePresence && !open && launcherButtonRef) setTimeout(() => {
launcherButtonRef.current?.focus();
});
}, [
enableDialogElement,
enablePresence,
open,
launcherButtonRef
]);
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(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 = isComponentElement(candidate, AILabel) ? cloneElement(candidate, { size: "sm" }) : candidate;
const modalBody = enableDialogElement ? /* @__PURE__ */ jsx(Dialog, {
open,
focusAfterCloseRef: launcherButtonRef,
modal: true,
className: containerClass,
"aria-label": ariaLabel ? ariaLabel : generatedAriaLabel,
"aria-labelledby": ariaLabelledBy,
"data-exiting": presenceContext?.isExiting || void 0,
children: /* @__PURE__ */ jsxs("div", {
ref: innerModal,
className: `${prefix}--modal-container-body`,
children: [slug ? normalizedDecorator : decorator ? /* @__PURE__ */ jsx("div", {
className: `${prefix}--modal--inner__decorator`,
children: normalizedDecorator
}) : "", childrenWithProps]
})
}) : /* @__PURE__ */ jsxs("div", {
className: containerClass,
role: "dialog",
"aria-modal": "true",
"aria-label": ariaLabel ? ariaLabel : generatedAriaLabel,
"aria-labelledby": ariaLabelledBy,
children: [
!focusTrapWithoutSentinels && /* @__PURE__ */ jsx("button", {
type: "button",
ref: startSentinel,
className: `${prefix}--visually-hidden`,
children: "Focus sentinel"
}),
/* @__PURE__ */ jsxs("div", {
ref: innerModal,
className: `${prefix}--modal-container-body`,
children: [slug ? normalizedDecorator : decorator ? /* @__PURE__ */ jsx("div", {
className: `${prefix}--modal--inner__decorator`,
children: normalizedDecorator
}) : "", childrenWithProps]
}),
!focusTrapWithoutSentinels && /* @__PURE__ */ jsx("button", {
type: "button",
ref: endSentinel,
className: `${prefix}--visually-hidden`,
children: "Focus sentinel"
})
]
});
return /* @__PURE__ */ jsx(Layer, {
...rest,
level: 0,
role: "presentation",
ref: mergedRefs,
"aria-hidden": !open,
onBlur: handleBlur,
onClick: composeEventHandlers([rest?.onClick, handleOnClick]),
onMouseDown: composeEventHandlers([rest?.onMouseDown, handleOnMouseDown]),
onKeyDown: handleKeyDown,
className: modalClass,
"data-exiting": presenceContext?.isExiting || void 0,
children: modalBody
});
});
ComposedModal.propTypes = {
["aria-label"]: PropTypes.string,
["aria-labelledby"]: PropTypes.string,
children: PropTypes.node,
className: PropTypes.string,
containerClassName: PropTypes.string,
danger: PropTypes.bool,
decorator: PropTypes.node,
isFullWidth: PropTypes.bool,
launcherButtonRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.any })]),
onClose: PropTypes.func,
onKeyDown: PropTypes.func,
open: PropTypes.bool,
preventCloseOnClickOutside: PropTypes.bool,
selectorPrimaryFocus: PropTypes.string,
selectorsFloatingMenus: PropTypes.arrayOf(PropTypes.string.isRequired),
size: PropTypes.oneOf([
"xs",
"sm",
"md",
"lg"
]),
slug: deprecate(PropTypes.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
export { ModalBody, ComposedModal as default };