UNPKG

@carbon/react

Version:

React components for the Carbon Design System

424 lines (411 loc) 17.1 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 reactIs = require('react-is'); var PropTypes = require('prop-types'); var index = require('../Layer/index.js'); var ModalHeader = require('./ModalHeader.js'); var ModalFooter = require('./ModalFooter.js'); var useIsomorphicEffect = require('../../internal/useIsomorphicEffect.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 index$3 = require('../Dialog/index.js'); var warning = require('../../internal/warning.js'); var index$2 = require('../AILabel/index.js'); var utils = require('../../internal/utils.js'); var debounce = require('../../node_modules/es-toolkit/dist/compat/function/debounce.mjs.js'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var React__default = /*#__PURE__*/_interopDefaultLegacy(React); var PropTypes__default = /*#__PURE__*/_interopDefaultLegacy(PropTypes); var cx__default = /*#__PURE__*/_interopDefaultLegacy(cx); const ModalBody = /*#__PURE__*/React__default["default"].forwardRef(function ModalBody({ className: customClassName, children, hasForm, hasScrollingContent, ...rest }, ref) { const prefix = usePrefix.usePrefix(); const contentRef = React.useRef(null); const [isScrollable, setIsScrollable] = React.useState(false); const contentClass = cx__default["default"]({ [`${prefix}--modal-content`]: true, [`${prefix}--modal-content--with-form`]: hasForm, [`${prefix}--modal-scroll-content`]: hasScrollingContent || isScrollable }, customClassName); useIsomorphicEffect["default"](() => { if (contentRef.current) { setIsScrollable(contentRef.current.scrollHeight > contentRef.current.clientHeight); } function handler() { if (contentRef.current) { setIsScrollable(contentRef.current.scrollHeight > contentRef.current.clientHeight); } } const debouncedHandler = debounce.debounce(handler, 200); window.addEventListener('resize', debouncedHandler); return () => { debouncedHandler.cancel(); window.removeEventListener('resize', debouncedHandler); }; }, []); const hasScrollingContentProps = hasScrollingContent || isScrollable ? { tabIndex: 0, role: 'region' } : {}; return /*#__PURE__*/React__default["default"].createElement(index.Layer, _rollupPluginBabelHelpers["extends"]({ className: contentClass }, hasScrollingContentProps, rest, { ref: mergeRefs["default"](contentRef, ref) }), children); }); ModalBody.propTypes = { /** * Required props for the accessibility label of the header */ ['aria-label']: requiredIfGivenPropIsTruthy["default"]('hasScrollingContent', PropTypes__default["default"].string), /** * Specify the content to be placed in the ModalBody */ children: PropTypes__default["default"].node, /** * Specify an optional className to be added to the Modal Body node */ className: PropTypes__default["default"].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__default["default"].bool, /** * Specify whether the modal contains scrolling content */ hasScrollingContent: PropTypes__default["default"].bool }; const ComposedModal = /*#__PURE__*/React__default["default"].forwardRef(function ComposedModal({ ['aria-labelledby']: ariaLabelledBy, ['aria-label']: ariaLabel, children, className: customClassName, containerClassName, danger, decorator, isFullWidth, onClose, onKeyDown, open, preventCloseOnClickOutside, selectorPrimaryFocus = '[data-modal-primary-focus]', selectorsFloatingMenus, size, launcherButtonRef, slug, ...rest }, ref) { const prefix = usePrefix.usePrefix(); const [isOpen, setIsOpen] = React.useState(!!open); const [wasOpen, setWasOpen] = React.useState(!!open); 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 enableDialogElement = index$1.useFeatureFlag('enable-dialog-element'); const focusTrapWithoutSentinels = index$1.useFeatureFlag('enable-experimental-focus-wrap-without-sentinels'); 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; // Keep track of modal open/close state // and propagate it to the document.body React.useEffect(() => { if (!enableDialogElement && open !== wasOpen) { setIsOpen(!!open); setWasOpen(!!open); toggleClass.toggleClass(document.body, `${prefix}--body--with-modal-open`, !!open); } }, [open, wasOpen, 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) { event.stopPropagation(); if (match.match(event, keys.Escape)) { closeModal(event); } 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 containsModalFooter = React.Children.toArray(childrenWithProps).some(child => utils.isComponentElement(child, ModalFooter.ModalFooter)); const isPassive = !containsModalFooter; const shouldCloseOnOutsideClick = isPassive ? preventCloseOnClickOutside !== false : preventCloseOnClickOutside === true; if (shouldCloseOnOutsideClick && target instanceof Node && !wrapFocus.elementOrParentIsFloatingMenu(target, selectorsFloatingMenus) && 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) }); } // 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; } const lastContent = modalContent.children[modalContent.children.length - 1]; const gradientSpacing = modalContent.scrollHeight - lastContent.offsetTop - lastContent.clientHeight; for (let elem of modalContent.children) { if (elem.contains(currentActiveNode)) { const spaceBelow = modalContent.clientHeight - elem.offsetTop + modalContent.scrollTop - elem.clientHeight; if (spaceBelow < gradientSpacing) { modalContent.scrollTop = modalContent.scrollTop + (gradientSpacing - spaceBelow); } break; } } } function closeModal(evt) { if (!onClose || onClose(evt) !== false) { setIsOpen(false); } } const modalClass = cx__default["default"](`${prefix}--modal`, { 'is-visible': isOpen, [`${prefix}--modal--danger`]: danger, [`${prefix}--modal--slug`]: slug, [`${prefix}--modal--decorator`]: decorator }, customClassName); const containerClass = cx__default["default"](`${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) let generatedAriaLabel; const childrenWithProps = React__default["default"].Children.toArray(children).map(child => { switch (true) { case reactIs.isElement(child) && child.type === React__default["default"].createElement(ModalHeader.ModalHeader).type: { const el = child; generatedAriaLabel = el.props.label; return /*#__PURE__*/React__default["default"].cloneElement(el, { closeModal }); } case reactIs.isElement(child) && child.type === React__default["default"].createElement(ModalFooter.ModalFooter).type: { const el = child; return /*#__PURE__*/React__default["default"].cloneElement(el, { closeModal, inputref: button, danger }); } default: return child; } }); React.useEffect(() => { if (!enableDialogElement && !open && launcherButtonRef) { setTimeout(() => { launcherButtonRef.current?.focus(); }); } }, [enableDialogElement, open, 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); } } }, [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' }) : null; const modalBody = enableDialogElement ? /*#__PURE__*/React__default["default"].createElement(index$3.unstable__Dialog, { open: open, focusAfterCloseRef: launcherButtonRef, modal: true, className: containerClass, "aria-label": ariaLabel ? ariaLabel : generatedAriaLabel, "aria-labelledby": ariaLabelledBy }, /*#__PURE__*/React__default["default"].createElement("div", { ref: innerModal, className: `${prefix}--modal-container-body` }, slug ? normalizedDecorator : decorator ? /*#__PURE__*/React__default["default"].createElement("div", { className: `${prefix}--modal--inner__decorator` }, normalizedDecorator) : '', childrenWithProps)) : /*#__PURE__*/React__default["default"].createElement("div", { className: containerClass, role: "dialog", "aria-modal": "true", "aria-label": ariaLabel ? ariaLabel : generatedAriaLabel, "aria-labelledby": ariaLabelledBy }, !focusTrapWithoutSentinels && /*#__PURE__*/React__default["default"].createElement("button", { type: "button", ref: startSentinel, className: `${prefix}--visually-hidden` }, "Focus sentinel"), /*#__PURE__*/React__default["default"].createElement("div", { ref: innerModal, className: `${prefix}--modal-container-body` }, slug ? normalizedDecorator : decorator ? /*#__PURE__*/React__default["default"].createElement("div", { className: `${prefix}--modal--inner__decorator` }, normalizedDecorator) : '', childrenWithProps), !focusTrapWithoutSentinels && /*#__PURE__*/React__default["default"].createElement("button", { type: "button", ref: endSentinel, className: `${prefix}--visually-hidden` }, "Focus sentinel")); return /*#__PURE__*/React__default["default"].createElement(index.Layer, _rollupPluginBabelHelpers["extends"]({}, rest, { level: 0, role: "presentation", ref: ref, "aria-hidden": !open, onBlur: handleBlur, onClick: events.composeEventHandlers([rest?.onClick, handleOnClick]), onMouseDown: events.composeEventHandlers([rest?.onMouseDown, handleOnMouseDown]), onKeyDown: handleKeyDown, className: modalClass }), modalBody); }); ComposedModal.propTypes = { /** * Specify the aria-label for cds--modal-container */ ['aria-label']: PropTypes__default["default"].string, /** * Specify the aria-labelledby for cds--modal-container */ ['aria-labelledby']: PropTypes__default["default"].string, /** * Specify the content to be placed in the ComposedModal */ children: PropTypes__default["default"].node, /** * Specify an optional className to be applied to the modal root node */ className: PropTypes__default["default"].string, /** * Specify an optional className to be applied to the modal node */ containerClassName: PropTypes__default["default"].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__default["default"].bool, /** * **Experimental**: Provide a `decorator` component to be rendered inside the `ComposedModal` component */ decorator: PropTypes__default["default"].node, /** * Specify whether the Modal content should have any inner padding. */ isFullWidth: PropTypes__default["default"].bool, /** * Provide a ref to return focus to once the modal is closed. */ launcherButtonRef: PropTypes__default["default"].oneOfType([PropTypes__default["default"].func, PropTypes__default["default"].shape({ current: PropTypes__default["default"].any })]), /** * Specify an optional handler for closing modal. * Returning `false` here prevents closing modal. */ onClose: PropTypes__default["default"].func, /** * Specify an optional handler for the `onKeyDown` event. Called for all * `onKeyDown` events that do not close the modal */ onKeyDown: PropTypes__default["default"].func, /** * Specify whether the Modal is currently open */ open: PropTypes__default["default"].bool, preventCloseOnClickOutside: PropTypes__default["default"].bool, /** * Specify a CSS selector that matches the DOM element that should be * focused when the Modal opens */ selectorPrimaryFocus: PropTypes__default["default"].string, /** * Specify the CSS selectors that match the floating menus */ selectorsFloatingMenus: PropTypes__default["default"].arrayOf(PropTypes__default["default"].string.isRequired), /** * Specify the size variant. */ size: PropTypes__default["default"].oneOf(['xs', 'sm', 'md', 'lg']), slug: deprecate["default"](PropTypes__default["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.') }; exports.ModalBody = ModalBody; exports["default"] = ComposedModal;