@carbon/react
Version:
React components for the Carbon Design System
423 lines (414 loc) • 15.8 kB
JavaScript
/**
* 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 };