UNPKG

@carbon/react

Version:

React components for the Carbon Design System

514 lines (512 loc) 23.3 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. */ 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;