UNPKG

@mskcc/carbon-react

Version:

Carbon react components for the MSKCC DSM

507 lines (502 loc) 17 kB
/** * MSKCC 2021, 2024 */ import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js'; import PropTypes from 'prop-types'; import React__default, { useRef, useState, useEffect } from 'react'; import cx from 'classnames'; import { Close } from '@carbon/icons-react'; import toggleClass from '../../tools/toggleClass.js'; import Button, { ButtonKinds } from '../Button/Button.js'; import '../Button/Button.Skeleton.js'; import ButtonSet from '../ButtonSet/ButtonSet.js'; import InlineLoading from '../MskInlineLoading/InlineLoading.js'; import { Layer } from '../MskLayer/index.js'; import wrapFocus, { wrapFocusWithoutSentinels, elementOrParentIsFloatingMenu } from '../../internal/wrapFocus.js'; import debounce from 'lodash.debounce'; import useIsomorphicEffect from '../../internal/useIsomorphicEffect.js'; import { useId } from '../../internal/useId.js'; import { usePrefix } from '../../internal/usePrefix.js'; import '../Text/index.js'; import { useFeatureFlag } from '../FeatureFlags/index.js'; import { composeEventHandlers } from '../../tools/events.js'; import requiredIfGivenPropIsTruthyV2 from '../../prop-types/requiredIfGivenPropIsTruthyv2.js'; import { Text } from '../Text/Text.js'; import { match } from '../../internal/keyboard/match.js'; import { Escape, Enter, Tab } from '../../internal/keyboard/keys.js'; const noopFn = () => {}; const ModalSizes = ['xs', 'sm', 'md', 'lg']; const Modal = /*#__PURE__*/React__default.forwardRef(function Modal(_ref, ref) { let { 'aria-label': ariaLabelProp, children, className, modalHeading = '', modalLabel = '', modalAriaLabel, passiveModal = false, secondaryButtonText, primaryButtonText, open, onRequestClose = noopFn, onRequestSubmit = noopFn, onSecondarySubmit, primaryButtonDisabled = false, danger, alert, secondaryButtons, selectorPrimaryFocus = '[data-modal-primary-focus]', selectorsFloatingMenus, shouldSubmitOnEnter, size, hasScrollingContent = false, closeButtonLabel = 'Close', preventCloseOnClickOutside = false, isFullWidth, launcherButtonRef, loadingStatus = 'inactive', loadingDescription, loadingIconDescription, onLoadingSuccess = noopFn, slug, primaryButtonKind = 'primary', primaryButtonClassName, secondaryButtonKind = 'tertiary', disableInitialFocus = false, ...rest } = _ref; const prefix = usePrefix(); const button = useRef(null); const secondaryButton = useRef(); const contentRef = useRef(null); const innerModal = useRef(null); const startTrap = useRef(null); const endTrap = useRef(null); const [isScrollable, setIsScrollable] = useState(false); const modalInstanceId = `modal-${useId()}`; const modalLabelId = `${prefix}--modal-header__label--${modalInstanceId}`; const modalHeadingId = `${prefix}--modal-header__heading--${modalInstanceId}`; const modalBodyId = `${prefix}--modal-body--${modalInstanceId}`; const modalCloseButtonClass = `${prefix}--modal-close`; const primaryButtonClass = cx({ primaryButtonClassName, [`${prefix}--btn--loading`]: loadingStatus !== 'inactive' }); const loadingActive = loadingStatus !== 'inactive'; const focusTrapWithoutSentinels = useFeatureFlag('enable-experimental-focus-wrap-without-sentinels'); function isCloseButton(element) { return !onSecondarySubmit && element === secondaryButton.current || element.classList.contains(modalCloseButtonClass); } function handleKeyDown(evt) { evt.stopPropagation(); if (open) { if (match(evt, Escape)) { onRequestClose(evt); } if (match(evt, Enter) && shouldSubmitOnEnter && !isCloseButton(evt.target)) { onRequestSubmit(evt); } if (focusTrapWithoutSentinels && match(evt, Tab) && innerModal.current) { wrapFocusWithoutSentinels({ containerNode: innerModal.current, currentActiveNode: evt.target, event: evt }); } } } function handleOnClick(evt) { const target = evt.target; evt.stopPropagation(); if (!preventCloseOnClickOutside && !elementOrParentIsFloatingMenu(target, selectorsFloatingMenus) && innerModal.current && !innerModal.current.contains(target)) { onRequestClose(evt); } } function handleBlur(_ref2) { let { target: oldActiveNode, relatedTarget: currentActiveNode } = _ref2; if (open && currentActiveNode && oldActiveNode) { const { current: bodyNode } = innerModal; const { current: startTrapNode } = startTrap; const { current: endTrapNode } = endTrap; wrapFocus({ bodyNode, startTrapNode, endTrapNode, currentActiveNode, oldActiveNode, selectorsFloatingMenus }); } } const onSecondaryButtonClick = onSecondarySubmit ? onSecondarySubmit : onRequestClose; const modalClasses = cx(`${prefix}--modal`, { [`${prefix}--modal-tall`]: !passiveModal, 'is-visible': open, [`${prefix}--modal--danger`]: danger, [`${prefix}--modal--slug`]: slug }, className); const containerClasses = cx(`${prefix}--modal-container`, { [`${prefix}--modal-container--${size}`]: size, [`${prefix}--modal-container--full-width`]: isFullWidth }); const contentClasses = cx(`${prefix}--modal-content`, { [`${prefix}--modal-scroll-content`]: hasScrollingContent || isScrollable }); const footerClasses = cx(`${prefix}--modal-footer`, { [`${prefix}--modal-footer--three-button`]: Array.isArray(secondaryButtons) && secondaryButtons.length === 2 }); const asStringOrUndefined = node => { return typeof node === 'string' ? node : undefined; }; const modalLabelStr = asStringOrUndefined(modalLabel); const modalHeadingStr = asStringOrUndefined(modalHeading); const ariaLabel = modalLabelStr || ariaLabelProp || modalAriaLabel || modalHeadingStr; const getAriaLabelledBy = modalLabel ? modalLabelId : modalHeadingId; const hasScrollingContentProps = hasScrollingContent || isScrollable ? { tabIndex: 0, role: 'region', 'aria-label': ariaLabel, 'aria-labelledby': getAriaLabelledBy } : {}; const alertDialogProps = {}; if (alert && passiveModal) { alertDialogProps.role = 'alert'; } if (alert && !passiveModal) { alertDialogProps.role = 'alertdialog'; alertDialogProps['aria-describedby'] = modalBodyId; } useEffect(() => { return () => { toggleClass(document.body, `${prefix}--body--with-modal-open`, false); }; }, [prefix]); useEffect(() => { toggleClass(document.body, `${prefix}--body--with-modal-open`, open ?? false); }, [open, prefix]); useEffect(() => { if (!open && launcherButtonRef) { setTimeout(() => { launcherButtonRef?.current?.focus(); }); } }, [open, launcherButtonRef]); useEffect(() => { const initialFocus = focusContainerElement => { if (disableInitialFocus) { return null; } const containerElement = focusContainerElement || innerModal.current; const primaryFocusElement = containerElement ? containerElement.querySelector(danger ? `.${prefix}--btn--tertiary` : selectorPrimaryFocus) : null; if (primaryFocusElement) { return primaryFocusElement; } return button && button.current; }; const focusButton = focusContainerElement => { const target = initialFocus(focusContainerElement); if (target !== null) { target.focus(); } }; if (open) { focusButton(innerModal.current); } }, [open, selectorPrimaryFocus, danger, prefix, disableInitialFocus]); 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); }; }, []); // Slug is always size `sm` let normalizedSlug; if (slug && slug['type']?.displayName === 'AILabel') { normalizedSlug = /*#__PURE__*/React__default.cloneElement(slug, { size: 'sm' }); } const modalButton = /*#__PURE__*/React__default.createElement("div", { className: `${prefix}--modal-close-button` }, /*#__PURE__*/React__default.createElement("button", { className: modalCloseButtonClass, type: 'button', onClick: onRequestClose, "aria-label": closeButtonLabel, ref: button }, /*#__PURE__*/React__default.createElement(Close, { size: 20, "aria-hidden": "true", tabIndex: "-1", className: `${modalCloseButtonClass}__icon` }))); const modalBody = /*#__PURE__*/React__default.createElement("div", _extends({ ref: innerModal, role: "dialog" }, alertDialogProps, { className: containerClasses, "aria-label": ariaLabel, "aria-modal": "true", tabIndex: -1 }), /*#__PURE__*/React__default.createElement("div", { className: `${prefix}--modal-header` }, passiveModal && modalButton, modalLabel && /*#__PURE__*/React__default.createElement(Text, { as: "h2", id: modalLabelId, className: `${prefix}--modal-header__label` }, modalLabel), /*#__PURE__*/React__default.createElement(Text, { as: "h3", id: modalHeadingId, className: `${prefix}--modal-header__heading` }, modalHeading), normalizedSlug, !passiveModal && modalButton), /*#__PURE__*/React__default.createElement(Layer, _extends({ ref: contentRef, id: modalBodyId, className: contentClasses }, hasScrollingContentProps), children), !passiveModal && /*#__PURE__*/React__default.createElement(ButtonSet, { className: footerClasses, "aria-busy": loadingActive }, Array.isArray(secondaryButtons) && secondaryButtons.length <= 2 ? secondaryButtons.map((_ref3, i) => { let { buttonText, onClick: onButtonClick } = _ref3; return /*#__PURE__*/React__default.createElement(Button, { key: `${buttonText}-${i}`, kind: secondaryButtonKind, onClick: onButtonClick }, buttonText); }) : secondaryButtonText && /*#__PURE__*/React__default.createElement(Button, { disabled: loadingActive, kind: secondaryButtonKind, onClick: onSecondaryButtonClick, ref: secondaryButton }, secondaryButtonText), /*#__PURE__*/React__default.createElement(Button, { className: primaryButtonClass, kind: danger ? 'danger' : primaryButtonKind, disabled: loadingActive || primaryButtonDisabled, onClick: onRequestSubmit, ref: button }, loadingStatus === 'inactive' ? primaryButtonText : /*#__PURE__*/React__default.createElement(InlineLoading, { status: loadingStatus, description: loadingDescription, iconDescription: loadingIconDescription, className: `${prefix}--inline-loading--btn`, onSuccess: onLoadingSuccess })))); return /*#__PURE__*/React__default.createElement(Layer, _extends({}, rest, { level: 0, onKeyDown: handleKeyDown, onClick: composeEventHandlers([rest?.onClick, handleOnClick]), onBlur: !focusTrapWithoutSentinels ? handleBlur : () => {}, className: modalClasses, role: "presentation", ref: ref }), !focusTrapWithoutSentinels && /*#__PURE__*/React__default.createElement("span", { ref: startTrap, tabIndex: 0, role: "link", className: `${prefix}--visually-hidden` }, "Focus sentinel"), modalBody, !focusTrapWithoutSentinels && /*#__PURE__*/React__default.createElement("span", { ref: endTrap, tabIndex: 0, role: "link", className: `${prefix}--visually-hidden` }, "Focus sentinel")); }); Modal.propTypes = { /** * Specify whether the Modal is displaying an alert, error or warning * Should go hand in hand with the danger prop. */ alert: PropTypes.bool, /** * Required props for the accessibility label of the header */ ['aria-label']: requiredIfGivenPropIsTruthyV2('hasScrollingContent', PropTypes.string), /** * Provide the contents of your Modal */ children: PropTypes.node, /** * Specify an optional className to be applied to the modal root node */ className: PropTypes.string, /** * Specify an label for the close button of the modal; defaults to close */ closeButtonLabel: PropTypes.string, /** * Specify whether the Modal is for dangerous actions */ danger: PropTypes.bool, /** * Specify whether the modal contains scrolling content */ hasScrollingContent: PropTypes.bool, /** * Specify the DOM element ID of the top-level node. */ id: PropTypes.string, /** * Specify whether or not 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 the description for the loading text */ loadingDescription: PropTypes.string, /** * Specify the description for the loading text */ loadingIconDescription: PropTypes.string, /** * loading status */ loadingStatus: PropTypes.oneOf(['inactive', 'active', 'finished', 'error']), /** * Specify a label to be read by screen readers on the modal root node */ modalAriaLabel: PropTypes.string, /** * Specify the content of the modal header title. */ modalHeading: PropTypes.node, /** * Specify the content of the modal header label. */ modalLabel: PropTypes.node, /** * Specify a handler for keypresses. */ onKeyDown: PropTypes.func, /** * Provide an optional handler to be invoked when loading is * successful */ onLoadingSuccess: PropTypes.func, /** * Specify a handler for closing modal. * The handler should care of closing modal, e.g. changing `open` prop. */ onRequestClose: PropTypes.func, /** * Specify a handler for "submitting" modal. * The handler should care of closing modal, e.g. changing `open` prop, if necessary. */ onRequestSubmit: PropTypes.func, /** * Specify a handler for the secondary button. * Useful if separate handler from `onRequestClose` is desirable */ onSecondarySubmit: PropTypes.func, /** * Specify whether the Modal is currently open */ open: PropTypes.bool, /** * Specify whether the modal should be button-less */ passiveModal: PropTypes.bool, /** * Prevent closing on click outside of modal */ preventCloseOnClickOutside: PropTypes.bool, /** * Specify whether the Button should be disabled, or not */ primaryButtonDisabled: PropTypes.bool, /** * Specify the text for the primary button */ primaryButtonText: PropTypes.node, /** * Specify the classnames of the primary button */ primaryButtonClassName: PropTypes.string, /** * Specify the text for the secondary button */ secondaryButtonText: PropTypes.node, /** * Specify an array of config objects for secondary buttons * (`Array<{ * buttonText: string, * onClick: function, * }>`). */ secondaryButtons: (props, propName, componentName) => { if (props.secondaryButtons) { if (!Array.isArray(props.secondaryButtons) || props.secondaryButtons.length !== 2) { return new Error(`${propName} needs to be an array of two button config objects`); } const shape = { buttonText: PropTypes.node, onClick: PropTypes.func }; props[propName].forEach(secondaryButton => { PropTypes.checkPropTypes(shape, secondaryButton, propName, componentName); }); } return null; }, /** * Specify a CSS selector that matches the DOM element that should * be focused when the Modal opens */ selectorPrimaryFocus: PropTypes.string, /** * Specify CSS selectors that match DOM elements working as floating menus. * Focusing on those elements won't trigger "focus-wrap" behavior */ selectorsFloatingMenus: PropTypes.arrayOf(PropTypes.string.isRequired), /** * Specify if Enter key should be used as "submit" action */ shouldSubmitOnEnter: PropTypes.bool, /** * Specify the size variant. */ size: PropTypes.oneOf(ModalSizes), /** * **Experimental**: Provide a `Slug` component to be rendered inside the `Modal` component */ slug: PropTypes.node, /** * Specify whether the initial focus should be disabled */ disableInitialFocus: PropTypes.bool, /** * Specify kind of the primary button */ primaryButtonKind: PropTypes.oneOf(ButtonKinds), /** * Specify kind of the secondary button * */ secondaryButtonKind: PropTypes.oneOf(ButtonKinds) }; export { ModalSizes, Modal as default };