@carbon/react
Version:
React components for the Carbon Design System
475 lines (460 loc) • 19 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 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;