@carbon/react
Version:
React components for the Carbon Design System
424 lines (411 loc) • 17.1 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.
*/
'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;