UNPKG

@carbon/react

Version:

React components for the Carbon Design System

423 lines (414 loc) 15.8 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. */ import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js'; import React, { useRef, useState, useEffect, Children, cloneElement } from 'react'; import { isElement } from 'react-is'; import PropTypes from 'prop-types'; import { Layer } from '../Layer/index.js'; import { ModalHeader } from './ModalHeader.js'; import { ModalFooter } from './ModalFooter.js'; import useIsomorphicEffect from '../../internal/useIsomorphicEffect.js'; import mergeRefs from '../../tools/mergeRefs.js'; import cx from 'classnames'; import { toggleClass } from '../../tools/toggleClass.js'; import requiredIfGivenPropIsTruthy from '../../prop-types/requiredIfGivenPropIsTruthy.js'; import { wrapFocusWithoutSentinels, wrapFocus, elementOrParentIsFloatingMenu } from '../../internal/wrapFocus.js'; import { usePrefix } from '../../internal/usePrefix.js'; import { Escape, Tab } from '../../internal/keyboard/keys.js'; import { match } from '../../internal/keyboard/match.js'; import { useFeatureFlag } from '../FeatureFlags/index.js'; import { composeEventHandlers } from '../../tools/events.js'; import { deprecate } from '../../prop-types/deprecate.js'; import { Dialog } from '../Dialog/Dialog.js'; import { warning } from '../../internal/warning.js'; import { AILabel } from '../AILabel/index.js'; import { isComponentElement } from '../../internal/utils.js'; import { debounce } from '../../node_modules/es-toolkit/dist/compat/function/debounce.js'; const ModalBody = /*#__PURE__*/React.forwardRef(function ModalBody({ className: customClassName, children, hasForm, hasScrollingContent, ...rest }, ref) { const prefix = usePrefix(); const contentRef = useRef(null); const [isScrollable, setIsScrollable] = useState(false); const contentClass = cx({ [`${prefix}--modal-content`]: true, [`${prefix}--modal-content--with-form`]: hasForm, [`${prefix}--modal-scroll-content`]: hasScrollingContent || isScrollable }, customClassName); useIsomorphicEffect(() => { 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(handler, 200); window.addEventListener('resize', debouncedHandler); return () => { debouncedHandler.cancel(); window.removeEventListener('resize', debouncedHandler); }; }, []); const hasScrollingContentProps = hasScrollingContent || isScrollable ? { tabIndex: 0, role: 'region' } : {}; return /*#__PURE__*/React.createElement(Layer, _extends({ className: contentClass }, hasScrollingContentProps, rest, { ref: mergeRefs(contentRef, ref) }), children); }); ModalBody.propTypes = { /** * Required props for the accessibility label of the header */ ['aria-label']: 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({ ['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(); const [isOpen, setIsOpen] = useState(!!open); const [wasOpen, setWasOpen] = useState(!!open); const innerModal = useRef(null); const button = useRef(null); const startSentinel = useRef(null); const endSentinel = useRef(null); const onMouseDownTarget = useRef(null); const enableDialogElement = useFeatureFlag('enable-dialog-element'); const focusTrapWithoutSentinels = useFeatureFlag('enable-experimental-focus-wrap-without-sentinels'); process.env.NODE_ENV !== "production" ? 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 useEffect(() => { if (!enableDialogElement && open !== wasOpen) { setIsOpen(!!open); setWasOpen(!!open); toggleClass(document.body, `${prefix}--body--with-modal-open`, !!open); } }, [open, wasOpen, prefix]); // Remove the document.body className on unmount useEffect(() => { if (!enableDialogElement) { return () => { 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(event, Tab) && innerModal.current) { 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 = Children.toArray(childrenWithProps).some(child => isComponentElement(child, ModalFooter)); const isPassive = !containsModalFooter; const shouldCloseOnOutsideClick = isPassive ? preventCloseOnClickOutside !== false : preventCloseOnClickOutside === true; if (shouldCloseOnOutsideClick && target instanceof Node && !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({ 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(`${prefix}--modal`, { 'is-visible': isOpen, [`${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) let generatedAriaLabel; const childrenWithProps = React.Children.toArray(children).map(child => { switch (true) { case isElement(child) && child.type === /*#__PURE__*/React.createElement(ModalHeader).type: { const el = child; generatedAriaLabel = el.props.label; return /*#__PURE__*/React.cloneElement(el, { closeModal }); } case isElement(child) && child.type === /*#__PURE__*/React.createElement(ModalFooter).type: { const el = child; return /*#__PURE__*/React.cloneElement(el, { closeModal, inputref: button, danger }); } default: return child; } }); 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 && !open && launcherButtonRef) { setTimeout(() => { launcherButtonRef.current?.focus(); }); } }, [enableDialogElement, open, 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]); // AILabel is always size `sm` const candidate = slug ?? decorator; const candidateIsAILabel = isComponentElement(candidate, AILabel); const normalizedDecorator = candidateIsAILabel ? /*#__PURE__*/cloneElement(candidate, { size: 'sm' }) : null; const modalBody = enableDialogElement ? /*#__PURE__*/React.createElement(Dialog, { open: open, focusAfterCloseRef: launcherButtonRef, modal: true, className: containerClass, "aria-label": ariaLabel ? ariaLabel : generatedAriaLabel, "aria-labelledby": ariaLabelledBy }, /*#__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(Layer, _extends({}, rest, { level: 0, role: "presentation", ref: ref, "aria-hidden": !open, onBlur: handleBlur, onClick: composeEventHandlers([rest?.onClick, handleOnClick]), onMouseDown: composeEventHandlers([rest?.onMouseDown, handleOnMouseDown]), onKeyDown: handleKeyDown, className: modalClass }), 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(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.') }; export { ModalBody, ComposedModal as default };