UNPKG

@carbon/react

Version:

React components for the Carbon Design System

330 lines (328 loc) 15.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. */ const require_runtime = require("../../_virtual/_rolldown/runtime.js"); const require_usePrefix = require("../../internal/usePrefix.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_warning = require("../../internal/warning.js"); const require_deprecate = require("../../prop-types/deprecate.js"); const require_utils = require("../../internal/utils.js"); const require_index = require("../FeatureFlags/index.js"); const require_index$1 = require("../AILabel/index.js"); const require_useResizeObserver = require("../../internal/useResizeObserver.js"); const require_events = require("../../tools/events.js"); const require_mergeRefs = require("../../tools/mergeRefs.js"); const require_index$2 = require("../Layer/index.js"); const require_ModalHeader = require("./ModalHeader.js"); const require_ModalFooter = require("./ModalFooter.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_useComposedModalState = require("./useComposedModalState.js"); const require_ComposedModalPresence = require("./ComposedModalPresence.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 _floating_ui_react = require("@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.default.forwardRef(function ModalBody({ className: customClassName, children, hasForm, hasScrollingContent, ...rest }, ref) { const prefix = require_usePrefix.usePrefix(); const contentRef = (0, react.useRef)(null); const { height } = require_useResizeObserver.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__ */ (0, react_jsx_runtime.jsx)(require_index$2.Layer, { className: (0, classnames.default)({ [`${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: require_mergeRefs.mergeRefs(contentRef, ref), children }); }); ModalBody.propTypes = { ["aria-label"]: require_requiredIfGivenPropIsTruthy.requiredIfGivenPropIsTruthy("hasScrollingContent", prop_types.default.string), children: prop_types.default.node, className: prop_types.default.string, hasForm: prop_types.default.bool, hasScrollingContent: prop_types.default.bool }; const ComposedModal = react.default.forwardRef(function ComposedModal({ open, ...props }, ref) { const id = require_useId.useId(); const enablePresence = require_index.useFeatureFlag("enable-presence"); const hasPresenceContext = Boolean((0, react.useContext)(require_ComposedModalPresence.ComposedModalPresenceContext)); const hasPresenceOptIn = enablePresence || hasPresenceContext; const exclusivePresenceContext = require_ComposedModalPresence.useExclusiveComposedModalPresenceContext(id); if (hasPresenceOptIn && !exclusivePresenceContext) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_ComposedModalPresence.ComposedModalPresence, { open: open ?? false, _presenceId: id, _autoEnablePresence: hasPresenceContext, children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ComposedModalDialog, { open: true, ref, ...props }) }); return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ComposedModalDialog, { ref, open, ...props }); }); const ComposedModalDialog = react.default.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 = require_usePrefix.usePrefix(); const innerModal = (0, react.useRef)(null); const button = (0, react.useRef)(null); const startSentinel = (0, react.useRef)(null); const endSentinel = (0, react.useRef)(null); const onMouseDownTarget = (0, react.useRef)(null); const presenceContext = (0, react.useContext)(require_ComposedModalPresence.ComposedModalPresenceContext); const mergedRefs = (0, _floating_ui_react.useMergeRefs)([ref, presenceContext?.presenceRef]); const enablePresence = require_index.useFeatureFlag("enable-presence") || presenceContext?.autoEnablePresence; const open = externalOpen || enablePresence; const modalState = require_useComposedModalState.useComposedModalState(open); const [isOpen, setIsOpen] = presenceContext?.modalState ?? modalState; const enableDialogElement = require_index.useFeatureFlag("enable-dialog-element"); const deprecatedFlag = require_index.useFeatureFlag("enable-experimental-focus-wrap-without-sentinels"); const focusTrapWithoutSentinelsFlag = require_index.useFeatureFlag("enable-focus-wrap-without-sentinels"); const focusTrapWithoutSentinels = deprecatedFlag || focusTrapWithoutSentinelsFlag; 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."); (0, react.useEffect)(() => { if (!enableDialogElement) require_toggleClass.toggleClass(document.body, `${prefix}--body--with-modal-open`, !!open); }, [open, prefix]); (0, react.useEffect)(() => { if (!enableDialogElement) return () => { require_toggleClass.toggleClass(document.body, `${prefix}--body--with-modal-open`, false); }; }, []); function handleKeyDown(event) { if (!enableDialogElement) { if (focusTrapWithoutSentinels && open && require_match.match(event, require_keys.Tab) && innerModal.current) require_wrapFocus.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 && !require_wrapFocus.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; require_wrapFocus.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 = (0, classnames.default)(`${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 = (0, classnames.default)(`${prefix}--modal-container`, size && `${prefix}--modal-container--${size}`, isFullWidth && `${prefix}--modal-container--full-width`, containerClassName); let generatedAriaLabel; const childrenWithProps = react.default.Children.toArray(children).map((child) => { if (require_utils.isComponentElement(child, require_ModalHeader.ModalHeader)) { generatedAriaLabel = child.props.label; return (0, react.cloneElement)(child, { closeModal }); } if (require_utils.isComponentElement(child, require_ModalFooter.ModalFooter)) return (0, react.cloneElement)(child, { closeModal, inputref: button, danger }); return child; }); const isPassive = !react.Children.toArray(childrenWithProps).some((child) => require_utils.isComponentElement(child, require_ModalFooter.ModalFooter)); require_warning.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"); (0, react.useEffect)(() => { if (!open) return; const handleEscapeKey = (event) => { if (require_match.match(event, require_keys.Escape)) { event.preventDefault(); event.stopPropagation(); closeModal(event); } }; document.addEventListener("keydown", handleEscapeKey, true); return () => { document.removeEventListener("keydown", handleEscapeKey, true); }; }, [open]); (0, react.useEffect)(() => { if (!enableDialogElement && !enablePresence && !open && launcherButtonRef) setTimeout(() => { launcherButtonRef.current?.focus(); }); }, [ enableDialogElement, enablePresence, open, launcherButtonRef ]); (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(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 = require_utils.isComponentElement(candidate, require_index$1.AILabel) ? (0, react.cloneElement)(candidate, { size: "sm" }) : candidate; const modalBody = enableDialogElement ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_Dialog.Dialog, { open, focusAfterCloseRef: launcherButtonRef, modal: true, className: containerClass, "aria-label": ariaLabel ? ariaLabel : generatedAriaLabel, "aria-labelledby": ariaLabelledBy, "data-exiting": presenceContext?.isExiting || void 0, children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { ref: innerModal, className: `${prefix}--modal-container-body`, children: [slug ? normalizedDecorator : decorator ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: `${prefix}--modal--inner__decorator`, children: normalizedDecorator }) : "", childrenWithProps] }) }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { className: containerClass, role: "dialog", "aria-modal": "true", "aria-label": ariaLabel ? ariaLabel : generatedAriaLabel, "aria-labelledby": ariaLabelledBy, children: [ !focusTrapWithoutSentinels && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { type: "button", ref: startSentinel, className: `${prefix}--visually-hidden`, children: "Focus sentinel" }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { ref: innerModal, className: `${prefix}--modal-container-body`, children: [slug ? normalizedDecorator : decorator ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: `${prefix}--modal--inner__decorator`, children: normalizedDecorator }) : "", childrenWithProps] }), !focusTrapWithoutSentinels && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { type: "button", ref: endSentinel, className: `${prefix}--visually-hidden`, children: "Focus sentinel" }) ] }); return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$2.Layer, { ...rest, level: 0, role: "presentation", ref: mergedRefs, "aria-hidden": !open, onBlur: handleBlur, onClick: require_events.composeEventHandlers([rest?.onClick, handleOnClick]), onMouseDown: require_events.composeEventHandlers([rest?.onMouseDown, handleOnMouseDown]), onKeyDown: handleKeyDown, className: modalClass, "data-exiting": presenceContext?.isExiting || void 0, children: modalBody }); }); ComposedModal.propTypes = { ["aria-label"]: prop_types.default.string, ["aria-labelledby"]: prop_types.default.string, children: prop_types.default.node, className: prop_types.default.string, containerClassName: prop_types.default.string, danger: prop_types.default.bool, decorator: prop_types.default.node, isFullWidth: prop_types.default.bool, launcherButtonRef: prop_types.default.oneOfType([prop_types.default.func, prop_types.default.shape({ current: prop_types.default.any })]), onClose: prop_types.default.func, onKeyDown: prop_types.default.func, open: prop_types.default.bool, preventCloseOnClickOutside: prop_types.default.bool, selectorPrimaryFocus: prop_types.default.string, selectorsFloatingMenus: prop_types.default.arrayOf(prop_types.default.string.isRequired), size: prop_types.default.oneOf([ "xs", "sm", "md", "lg" ]), slug: require_deprecate.deprecate(prop_types.default.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 exports.ModalBody = ModalBody; exports.default = ComposedModal;