@mskcc/carbon-react
Version:
Carbon react components for the MSKCC DSM
284 lines (277 loc) • 9.2 kB
JavaScript
/**
* MSKCC 2021, 2024
*/
import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js';
import React__default, { useState, useRef, useEffect } from 'react';
import { isElement } from 'react-is';
import PropTypes from 'prop-types';
import { ModalHeader } from './ModalHeader.js';
import { ModalFooter } from './ModalFooter.js';
import cx from 'classnames';
import toggleClass from '../../tools/toggleClass.js';
import requiredIfGivenPropIsTruthy from '../../prop-types/requiredIfGivenPropIsTruthy.js';
import wrapFocus from '../../internal/wrapFocus.js';
import { usePrefix } from '../../internal/usePrefix.js';
import { match } from '../../internal/keyboard/match.js';
import { Escape } from '../../internal/keyboard/keys.js';
const ModalBody = /*#__PURE__*/React__default.forwardRef(function ModalBody(_ref, ref) {
let {
className: customClassName,
children,
hasForm,
hasScrollingContent,
...rest
} = _ref;
const prefix = usePrefix();
const contentClass = cx(`${prefix}--modal-content`, hasForm && `${prefix}--modal-content--with-form`, hasScrollingContent && `${prefix}--modal-scroll-content`, customClassName);
const hasScrollingContentProps = hasScrollingContent ? {
tabIndex: 0,
role: 'region'
} : {};
return /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement("div", _extends({
className: contentClass
}, hasScrollingContentProps, rest, {
ref: ref
}), children), hasScrollingContent && /*#__PURE__*/React__default.createElement("div", {
className: `${prefix}--modal-content--overflow-indicator`
}));
});
ModalBody.propTypes = {
/**
* Required props for the accessibility label of the header
*/
// @ts-expect-error: Built-in prop-types > TS logic doesn't jive well with custom validators
['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 `bx--modal-content__regular-content` class.
*/
hasForm: PropTypes.bool,
/**
* Specify whether the modal contains scrolling content
*/
hasScrollingContent: PropTypes.bool
};
const ComposedModal = /*#__PURE__*/React__default.forwardRef(function ComposedModal(_ref2, ref) {
let {
['aria-labelledby']: ariaLabelledBy,
['aria-label']: ariaLabel,
children,
className: customClassName,
containerClassName,
danger,
isFullWidth,
onClose,
onKeyDown,
open,
preventCloseOnClickOutside,
selectorPrimaryFocus,
selectorsFloatingMenus,
size,
...rest
} = _ref2;
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);
// Kepp track of modal open/close state
// and propagate it to the document.body
useEffect(() => {
if (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(() => {
return () => {
toggleClass(document.body, `${prefix}--body--with-modal-open`, false);
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
function handleKeyDown(evt) {
if (match(evt, Escape)) {
closeModal(evt);
}
onKeyDown?.(evt);
}
function handleMousedown(evt) {
const isInside = innerModal.current?.contains(evt.target);
if (!isInside && !preventCloseOnClickOutside) {
closeModal(evt);
}
}
function handleBlur(_ref3) {
let {
target: oldActiveNode,
relatedTarget: currentActiveNode
} = _ref3;
if (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)
});
}
}
function closeModal(evt) {
if (!onClose || onClose(evt) !== false) {
setIsOpen(false);
}
}
const modalClass = cx(`${prefix}--modal`, isOpen && 'is-visible', danger && `${prefix}--modal--danger`, 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__default.Children.toArray(children).map(child => {
switch (true) {
case isElement(child) && child.type === React__default.createElement(ModalHeader).type:
{
const el = child;
generatedAriaLabel = el.props.label;
return /*#__PURE__*/React__default.cloneElement(el, {
closeModal
});
}
case isElement(child) && child.type === React__default.createElement(ModalFooter).type:
{
const el = child;
return /*#__PURE__*/React__default.cloneElement(el, {
closeModal,
inputref: button
});
}
default:
return child;
}
});
useEffect(() => {
const focusButton = focusContainerElement => {
if (focusContainerElement) {
const primaryFocusElement = focusContainerElement.querySelector(selectorPrimaryFocus);
if (primaryFocusElement) {
primaryFocusElement.focus();
return;
}
if (button.current) {
button.current.focus();
}
}
};
if (!open) {
return;
}
if (innerModal.current) {
focusButton(innerModal.current);
}
}, [open, selectorPrimaryFocus]);
return /*#__PURE__*/React__default.createElement("div", _extends({}, rest, {
role: "presentation",
ref: ref,
"aria-hidden": !open,
onBlur: handleBlur,
onMouseDown: handleMousedown,
onKeyDown: handleKeyDown,
className: modalClass
}), /*#__PURE__*/React__default.createElement("div", {
className: containerClass,
role: "dialog",
"aria-modal": "true",
"aria-label": ariaLabel ? ariaLabel : generatedAriaLabel,
"aria-labelledby": ariaLabelledBy
}, /*#__PURE__*/React__default.createElement("button", {
type: "button",
ref: startSentinel,
className: `${prefix}--visually-hidden`
}, "Focus sentinel"), /*#__PURE__*/React__default.createElement("div", {
ref: innerModal,
className: `${prefix}--modal-container-body`
}, childrenWithProps), /*#__PURE__*/React__default.createElement("button", {
type: "button",
ref: endSentinel,
className: `${prefix}--visually-hidden`
}, "Focus sentinel")));
});
ComposedModal.propTypes = {
/**
* Specify the aria-label for bx--modal-container
*/
['aria-label']: PropTypes.string,
/**
* Specify the aria-labelledby for bx--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,
/**
* Specify whether the Modal content should have any inner padding.
*/
isFullWidth: PropTypes.bool,
/**
* 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),
/**
* Specify the size variant.
*/
size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg'])
};
export { ModalBody, ComposedModal as default };