UNPKG

@carbon/react

Version:

React components for the Carbon Design System

510 lines (508 loc) 20.7 kB
/** * 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 };