UNPKG

@carbon/react

Version:

React components for the Carbon Design System

475 lines (460 loc) 19 kB
/** * Copyright IBM Corp. 2016, 2023 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var _rollupPluginBabelHelpers = require('../../_virtual/_rollupPluginBabelHelpers.js'); var React = require('react'); var useResizeObserver = require('../../internal/useResizeObserver.js'); var PropTypes = require('prop-types'); var index = require('../Layer/index.js'); var ModalHeader = require('./ModalHeader.js'); var ModalFooter = require('./ModalFooter.js'); var mergeRefs = require('../../tools/mergeRefs.js'); var cx = require('classnames'); var toggleClass = require('../../tools/toggleClass.js'); var requiredIfGivenPropIsTruthy = require('../../prop-types/requiredIfGivenPropIsTruthy.js'); var wrapFocus = require('../../internal/wrapFocus.js'); var usePrefix = require('../../internal/usePrefix.js'); var keys = require('../../internal/keyboard/keys.js'); var match = require('../../internal/keyboard/match.js'); var index$1 = require('../FeatureFlags/index.js'); var events = require('../../tools/events.js'); var deprecate = require('../../prop-types/deprecate.js'); var Dialog = require('../Dialog/Dialog.js'); var warning = require('../../internal/warning.js'); var index$2 = require('../AILabel/index.js'); var utils = require('../../internal/utils.js'); var react = require('@floating-ui/react'); var ComposedModalPresence = require('./ComposedModalPresence.js'); var useId = require('../../internal/useId.js'); var useComposedModalState = require('./useComposedModalState.js'); const ModalBody = /*#__PURE__*/React.forwardRef(function ModalBody({ className: customClassName, children, hasForm, hasScrollingContent, ...rest }, ref) { const prefix = usePrefix.usePrefix(); const contentRef = React.useRef(null); const { height } = 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; const contentClass = cx({ [`${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); const hasScrollingContentProps = hasScrollingContent || isScrollable ? { tabIndex: 0, role: 'region' } : {}; return /*#__PURE__*/React.createElement(index.Layer, _rollupPluginBabelHelpers.extends({ className: contentClass }, hasScrollingContentProps, rest, { ref: mergeRefs.mergeRefs(contentRef, ref) }), children); }); ModalBody.propTypes = { /** * Required props for the accessibility label of the header */ ['aria-label']: requiredIfGivenPropIsTruthy.requiredIfGivenPropIsTruthy('hasScrollingContent', PropTypes.string), /** * Specify the content to be placed in the ModalBody */ children: PropTypes.node, /** * Specify an optional className to be added to the Modal Body node */ className: PropTypes.string, /** * Provide whether the modal content has a form element. * If `true` is used here, non-form child content should have `cds--modal-content__regular-content` class. */ hasForm: PropTypes.bool, /** * Specify whether the modal contains scrolling content */ hasScrollingContent: PropTypes.bool }; const ComposedModal = /*#__PURE__*/React.forwardRef(function ComposedModal({ open, ...props }, ref) { const id = useId.useId(); const enablePresence = index$1.useFeatureFlag('enable-presence'); const hasPresenceContext = Boolean(React.useContext(ComposedModalPresence.ComposedModalPresenceContext)); const hasPresenceOptIn = enablePresence || hasPresenceContext; const exclusivePresenceContext = ComposedModalPresence.useExclusiveComposedModalPresenceContext(id); // if opt in and not exclusive to a presence context, wrap with presence if (hasPresenceOptIn && !exclusivePresenceContext) { return /*#__PURE__*/React.createElement(ComposedModalPresence.ComposedModalPresence, { open: open ?? false, _presenceId: id // do not auto enable styles for opt-in by feature flag , _autoEnablePresence: hasPresenceContext }, /*#__PURE__*/React.createElement(ComposedModalDialog, _rollupPluginBabelHelpers.extends({ open: true, ref: ref }, props))); } return /*#__PURE__*/React.createElement(ComposedModalDialog, _rollupPluginBabelHelpers.extends({ ref: ref, open: open }, props)); }); const ComposedModalDialog = /*#__PURE__*/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.usePrefix(); const innerModal = React.useRef(null); const button = React.useRef(null); const startSentinel = React.useRef(null); const endSentinel = React.useRef(null); const onMouseDownTarget = React.useRef(null); const presenceContext = React.useContext(ComposedModalPresence.ComposedModalPresenceContext); const mergedRefs = react.useMergeRefs([ref, presenceContext?.presenceRef]); const enablePresence = index$1.useFeatureFlag('enable-presence') || presenceContext?.autoEnablePresence; // always mark as open when mounted with presence const open = externalOpen || enablePresence; const modalState = useComposedModalState.useComposedModalState(open); const [isOpen, setIsOpen] = presenceContext?.modalState ?? modalState; const enableDialogElement = index$1.useFeatureFlag('enable-dialog-element'); const deprecatedFlag = index$1.useFeatureFlag('enable-experimental-focus-wrap-without-sentinels'); const focusTrapWithoutSentinelsFlag = index$1.useFeatureFlag('enable-focus-wrap-without-sentinels'); const focusTrapWithoutSentinels = deprecatedFlag || focusTrapWithoutSentinelsFlag; process.env.NODE_ENV !== "production" ? 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.') : void 0; // Propagate open/close state to the document.body React.useEffect(() => { if (!enableDialogElement) { toggleClass.toggleClass(document.body, `${prefix}--body--with-modal-open`, !!open); } // eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452 }, [open, prefix]); // Remove the document.body className on unmount React.useEffect(() => { if (!enableDialogElement) { return () => { toggleClass.toggleClass(document.body, `${prefix}--body--with-modal-open`, false); }; } }, []); // eslint-disable-line react-hooks/exhaustive-deps function handleKeyDown(event) { if (!enableDialogElement) { if (focusTrapWithoutSentinels && open && match.match(event, keys.Tab) && innerModal.current) { wrapFocus.wrapFocusWithoutSentinels({ containerNode: innerModal.current, currentActiveNode: event.target, event: event }); } } onKeyDown?.(event); } function handleOnMouseDown(evt) { const target = evt.target; onMouseDownTarget.current = target; } function handleOnClick(evt) { const { target } = evt; const mouseDownTarget = onMouseDownTarget.current; evt.stopPropagation(); const shouldCloseOnOutsideClick = // Passive modals can close on clicks outside the modal when // preventCloseOnClickOutside is undefined or explicitly set to false. isPassive && !preventCloseOnClickOutside || // Non-passive modals have to explicitly opt-in for close on outside // behavior by explicitly setting preventCloseOnClickOutside to false, // rather than just leaving it undefined. !isPassive && preventCloseOnClickOutside === false; if (shouldCloseOnOutsideClick && target instanceof Node && !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; wrapFocus.wrapFocus({ bodyNode, startTrapNode: startSentinelNode, endTrapNode: endSentinelNode, currentActiveNode, oldActiveNode, selectorsFloatingMenus: selectorsFloatingMenus?.filter(Boolean), prefix }); } // Adjust scroll if needed so that element with focus is not obscured by gradient 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 = cx(`${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 = cx(`${prefix}--modal-container`, size && `${prefix}--modal-container--${size}`, isFullWidth && `${prefix}--modal-container--full-width`, containerClassName); // Generate aria-label based on Modal Header label if one is not provided (L253) // // TODO: Confirm whether `ModalHeader` `label` should allow `ReactNode`. If // so, define how to derive a string for `aria-label`. let generatedAriaLabel; const childrenWithProps = React.Children.toArray(children).map(child => { if (utils.isComponentElement(child, ModalHeader.ModalHeader)) { generatedAriaLabel = child.props.label; return /*#__PURE__*/React.cloneElement(child, { closeModal }); } if (utils.isComponentElement(child, ModalFooter.ModalFooter)) { return /*#__PURE__*/React.cloneElement(child, { closeModal, inputref: button, danger }); } return child; }); // Modals without a footer are considered passive and carry limitations as // outlined in the design spec. const containsModalFooter = React.Children.toArray(childrenWithProps).some(child => utils.isComponentElement(child, ModalFooter.ModalFooter)); const isPassive = !containsModalFooter; process.env.NODE_ENV !== "production" ? 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') : void 0; React.useEffect(() => { if (!open) return; const handleEscapeKey = event => { if (match.match(event, keys.Escape)) { event.preventDefault(); event.stopPropagation(); closeModal(event); } }; document.addEventListener('keydown', handleEscapeKey, true); return () => { document.removeEventListener('keydown', handleEscapeKey, true); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452 }, [open]); React.useEffect(() => { if (!enableDialogElement && !enablePresence && !open && launcherButtonRef) { setTimeout(() => { launcherButtonRef.current?.focus(); }); } }, [enableDialogElement, enablePresence, open, launcherButtonRef]); // Focus launcherButtonRef on unmount React.useEffect(() => { const launcherButton = launcherButtonRef?.current; return () => { if (enablePresence && launcherButton) { setTimeout(() => { launcherButton.focus(); }); } }; }, [enablePresence, launcherButtonRef]); 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); } } // eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452 }, [open, selectorPrimaryFocus, isOpen]); // AILabel is always size `sm` const candidate = slug ?? decorator; const candidateIsAILabel = utils.isComponentElement(candidate, index$2.AILabel); const normalizedDecorator = candidateIsAILabel ? /*#__PURE__*/React.cloneElement(candidate, { size: 'sm' }) : candidate; const modalBody = enableDialogElement ? /*#__PURE__*/React.createElement(Dialog.Dialog, { open: open, focusAfterCloseRef: launcherButtonRef, modal: true, className: containerClass, "aria-label": ariaLabel ? ariaLabel : generatedAriaLabel, "aria-labelledby": ariaLabelledBy, "data-exiting": presenceContext?.isExiting || undefined }, /*#__PURE__*/React.createElement("div", { ref: innerModal, className: `${prefix}--modal-container-body` }, slug ? normalizedDecorator : decorator ? /*#__PURE__*/React.createElement("div", { className: `${prefix}--modal--inner__decorator` }, normalizedDecorator) : '', childrenWithProps)) : /*#__PURE__*/React.createElement("div", { className: containerClass, role: "dialog", "aria-modal": "true", "aria-label": ariaLabel ? ariaLabel : generatedAriaLabel, "aria-labelledby": ariaLabelledBy }, !focusTrapWithoutSentinels && /*#__PURE__*/React.createElement("button", { type: "button", ref: startSentinel, className: `${prefix}--visually-hidden` }, "Focus sentinel"), /*#__PURE__*/React.createElement("div", { ref: innerModal, className: `${prefix}--modal-container-body` }, slug ? normalizedDecorator : decorator ? /*#__PURE__*/React.createElement("div", { className: `${prefix}--modal--inner__decorator` }, normalizedDecorator) : '', childrenWithProps), !focusTrapWithoutSentinels && /*#__PURE__*/React.createElement("button", { type: "button", ref: endSentinel, className: `${prefix}--visually-hidden` }, "Focus sentinel")); return /*#__PURE__*/React.createElement(index.Layer, _rollupPluginBabelHelpers.extends({}, rest, { level: 0, role: "presentation", ref: mergedRefs, "aria-hidden": !open, onBlur: handleBlur, onClick: events.composeEventHandlers([rest?.onClick, handleOnClick]), onMouseDown: events.composeEventHandlers([rest?.onMouseDown, handleOnMouseDown]), onKeyDown: handleKeyDown, className: modalClass, "data-exiting": presenceContext?.isExiting || undefined }), modalBody); }); ComposedModal.propTypes = { /** * Specify the aria-label for cds--modal-container */ ['aria-label']: PropTypes.string, /** * Specify the aria-labelledby for cds--modal-container */ ['aria-labelledby']: PropTypes.string, /** * Specify the content to be placed in the ComposedModal */ children: PropTypes.node, /** * Specify an optional className to be applied to the modal root node */ className: PropTypes.string, /** * Specify an optional className to be applied to the modal node */ containerClassName: PropTypes.string, /** * Specify whether the primary button should be replaced with danger button. * Note that this prop is not applied if you render primary/danger button by yourself */ danger: PropTypes.bool, /** * **Experimental**: Provide a `decorator` component to be rendered inside the `ComposedModal` component */ decorator: PropTypes.node, /** * Specify whether the Modal content should have any inner padding. */ isFullWidth: PropTypes.bool, /** * Provide a ref to return focus to once the modal is closed. */ launcherButtonRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.any })]), /** * Specify an optional handler for closing modal. * Returning `false` here prevents closing modal. */ onClose: PropTypes.func, /** * Specify an optional handler for the `onKeyDown` event. Called for all * `onKeyDown` events that do not close the modal */ onKeyDown: PropTypes.func, /** * Specify whether the Modal is currently open */ open: PropTypes.bool, preventCloseOnClickOutside: PropTypes.bool, /** * Specify a CSS selector that matches the DOM element that should be * focused when the Modal opens */ selectorPrimaryFocus: PropTypes.string, /** * Specify the CSS selectors that match the floating menus */ selectorsFloatingMenus: PropTypes.arrayOf(PropTypes.string.isRequired), /** * Specify the size variant. */ size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']), slug: deprecate.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.') }; exports.ModalBody = ModalBody; exports.default = ComposedModal;