UNPKG

@carbon/react

Version:

React components for the Carbon Design System

325 lines (323 loc) 13.9 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 { 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 };