UNPKG

@carbon/react

Version:

React components for the Carbon Design System

679 lines (667 loc) 26.4 kB
/** * 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 PropTypes = require('prop-types'); var React = require('react'); var cx = require('classnames'); var iconsReact = require('@carbon/icons-react'); var toggleClass = require('../../tools/toggleClass.js'); var Button = require('../Button/Button.js'); require('../Button/Button.Skeleton.js'); var ButtonSet = require('../ButtonSet/ButtonSet.js'); var InlineLoading = require('../InlineLoading/InlineLoading.js'); var index$3 = require('../Layer/index.js'); var requiredIfGivenPropIsTruthy = require('../../prop-types/requiredIfGivenPropIsTruthy.js'); var wrapFocus = require('../../internal/wrapFocus.js'); var useResizeObserver = require('../../internal/useResizeObserver.js'); var useId = require('../../internal/useId.js'); var useMergedRefs = require('../../internal/useMergedRefs.js'); var usePrefix = require('../../internal/usePrefix.js'); var usePreviousValue = require('../../internal/usePreviousValue.js'); var keys = require('../../internal/keyboard/keys.js'); var match = require('../../internal/keyboard/match.js'); var index$2 = require('../IconButton/index.js'); var noopFn = require('../../internal/noopFn.js'); var Text = require('../Text/Text.js'); require('../Text/TextDirection.js'); var index = require('../FeatureFlags/index.js'); var events = require('../../tools/events.js'); var deprecate = require('../../prop-types/deprecate.js'); var Dialog = require('../Dialog/Dialog.js'); var index$1 = require('../AILabel/index.js'); var utils = require('../../internal/utils.js'); var warning = require('../../internal/warning.js'); var ModalPresence = require('./ModalPresence.js'); const ModalSizes = ['xs', 'sm', 'md', 'lg']; const invalidOutsideClickMessage = '`<Modal>` prop `preventCloseOnClickOutside` should not be `false` when ' + '`passiveModal` is `false`. Transactional, non-passive Modals should ' + 'not be dissmissable by clicking outside. ' + 'See: https://carbondesignsystem.com/components/modal/usage/#transactional-modal'; const Modal = /*#__PURE__*/React.forwardRef(function Modal({ open, ...props }, ref) { const id = useId.useId(); const enablePresence = index.useFeatureFlag('enable-presence'); const hasPresenceContext = Boolean(React.useContext(ModalPresence.ModalPresenceContext)); const hasPresenceOptIn = enablePresence || hasPresenceContext; const exclusivePresenceContext = ModalPresence.useExclusiveModalPresenceContext(id); // if opt in and not exclusive to a presence context, wrap with presence if (hasPresenceOptIn && !exclusivePresenceContext) { return /*#__PURE__*/React.createElement(ModalPresence.ModalPresence, { open: open ?? false, _presenceId: id // do not auto enable styles for opt-in by feature flag , _autoEnablePresence: hasPresenceContext }, /*#__PURE__*/React.createElement(ModalDialog, _rollupPluginBabelHelpers.extends({ open: true, ref: ref }, props))); } return /*#__PURE__*/React.createElement(ModalDialog, _rollupPluginBabelHelpers.extends({ ref: ref, open: open }, props)); }); const ModalDialog = /*#__PURE__*/React.forwardRef(function ModalDialog({ 'aria-label': ariaLabelProp, children, className, decorator, modalHeading = '', modalLabel = '', modalAriaLabel, passiveModal = false, secondaryButtonText, primaryButtonText, open: externalOpen, onRequestClose = noopFn.noopFn, onRequestSubmit = noopFn.noopFn, onSecondarySubmit, primaryButtonDisabled = false, danger, alert, secondaryButtons, selectorPrimaryFocus = '[data-modal-primary-focus]', selectorsFloatingMenus, shouldSubmitOnEnter, size, hasScrollingContent = false, closeButtonLabel = 'Close', preventCloseOnClickOutside, isFullWidth, launcherButtonRef, loadingStatus = 'inactive', loadingDescription, loadingIconDescription, onLoadingSuccess = noopFn.noopFn, slug, ...rest }, ref) { const prefix = usePrefix.usePrefix(); const button = React.useRef(null); const secondaryButton = React.useRef(null); const contentRef = React.useRef(null); const innerModal = React.useRef(null); const startTrap = React.useRef(null); const endTrap = React.useRef(null); const wrapFocusTimeout = React.useRef(null); const modalInstanceId = `modal-${useId.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({ [`${prefix}--btn--loading`]: loadingStatus !== 'inactive' }); const loadingActive = loadingStatus !== 'inactive'; const presenceContext = React.useContext(ModalPresence.ModalPresenceContext); const mergedRefs = useMergedRefs.useMergedRefs([ref, presenceContext?.presenceRef]); const enablePresence = index.useFeatureFlag('enable-presence') || presenceContext?.autoEnablePresence; // always mark as open when mounted with presence const open = externalOpen || enablePresence; const prevOpen = usePreviousValue.usePreviousValue(open); const deprecatedFlag = index.useFeatureFlag('enable-experimental-focus-wrap-without-sentinels'); const focusTrapWithoutSentinelsFlag = index.useFeatureFlag('enable-focus-wrap-without-sentinels'); const focusTrapWithoutSentinels = focusTrapWithoutSentinelsFlag || deprecatedFlag; const enableDialogElement = index.useFeatureFlag('enable-dialog-element'); 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; process.env.NODE_ENV !== "production" ? warning.warning(!(!passiveModal && preventCloseOnClickOutside === false), invalidOutsideClickMessage) : void 0; function isCloseButton(element) { return !onSecondarySubmit && element === secondaryButton.current || element.classList.contains(modalCloseButtonClass); } function handleKeyDown(evt) { const { target } = evt; evt.stopPropagation(); if (open && target instanceof HTMLElement) { if (match.match(evt, keys.Enter) && shouldSubmitOnEnter && !isCloseButton(target) && document.activeElement !== button.current) { onRequestSubmit(evt); } if (focusTrapWithoutSentinels && !enableDialogElement && match.match(evt, keys.Tab) && innerModal.current) { wrapFocus.wrapFocusWithoutSentinels({ containerNode: innerModal.current, currentActiveNode: target, event: evt }); } } } function handleOnClick(evt) { const { target } = evt; evt.stopPropagation(); const shouldCloseOnOutsideClick = // Passive modals can close on clicks outside the modal when // preventCloseOnClickOutside is undefined or explicitly set to false. passiveModal && !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. !passiveModal && preventCloseOnClickOutside === false; if (shouldCloseOnOutsideClick && target instanceof Node && !wrapFocus.elementOrParentIsFloatingMenu(target, selectorsFloatingMenus, prefix) && innerModal.current && !innerModal.current.contains(target)) { onRequestClose(evt); } } function handleBlur({ target: oldActiveNode, relatedTarget: currentActiveNode }) { if (!enableDialogElement && open && oldActiveNode instanceof HTMLElement && currentActiveNode instanceof HTMLElement) { const { current: bodyNode } = innerModal; const { current: startTrapNode } = startTrap; const { current: endTrapNode } = endTrap; // use setTimeout to ensure focus is set after all browser default focus behavior. Fixes issue of // focus not wrapping in Firefox wrapFocusTimeout.current = setTimeout(() => { wrapFocus.wrapFocus({ bodyNode, startTrapNode, endTrapNode, currentActiveNode, oldActiveNode, selectorsFloatingMenus, prefix }); if (wrapFocusTimeout.current) { clearTimeout(wrapFocusTimeout.current); } }); } // 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' }); } const onSecondaryButtonClick = onSecondarySubmit ? onSecondarySubmit : onRequestClose; const { height } = useResizeObserver.useResizeObserver({ ref: contentRef }); const modalClasses = cx(`${prefix}--modal`, { [`${prefix}--modal-tall`]: !passiveModal, 'is-visible': enablePresence || open, [`${prefix}--modal--enable-presence`]: presenceContext?.autoEnablePresence, [`${prefix}--modal--danger`]: danger, [`${prefix}--modal--slug`]: slug, [`${prefix}--modal--decorator`]: decorator }, className); const containerClasses = cx(`${prefix}--modal-container`, { [`${prefix}--modal-container--${size}`]: size, [`${prefix}--modal-container--full-width`]: isFullWidth }); /** * 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 contentClasses = cx(`${prefix}--modal-content`, { [`${prefix}--modal-scroll-content`]: hasScrollingContent || isScrollable, [`${prefix}--modal-scroll-content--no-fade`]: height <= 300 }); 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; } React.useEffect(() => { if (!open) return; const handleEscapeKey = event => { if (match.match(event, keys.Escape)) { event.preventDefault(); event.stopPropagation(); onRequestClose(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(() => { return () => { if (!enableDialogElement) { toggleClass.toggleClass(document.body, `${prefix}--body--with-modal-open`, false); } }; }, [prefix, enableDialogElement]); React.useEffect(() => { if (!enableDialogElement) { toggleClass.toggleClass(document.body, `${prefix}--body--with-modal-open`, open ?? false); } }, [open, prefix, enableDialogElement]); React.useEffect(() => { if (!enableDialogElement && !enablePresence && prevOpen && !open && launcherButtonRef) { setTimeout(() => { if ('current' in launcherButtonRef) { launcherButtonRef.current?.focus(); } }); } }, [open, prevOpen, launcherButtonRef, enableDialogElement, enablePresence]); // 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(selectorPrimaryFocus) || danger && containerElement.querySelector(`.${prefix}--btn--secondary`)); 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, enableDialogElement]); // AILabel always size `sm` const candidate = slug ?? decorator; const candidateIsAILabel = utils.isComponentElement(candidate, index$1.AILabel); const normalizedDecorator = candidateIsAILabel ? /*#__PURE__*/React.cloneElement(candidate, { size: 'sm' }) : candidate; const modalButton = /*#__PURE__*/React.createElement("div", { className: `${prefix}--modal-close-button` }, /*#__PURE__*/React.createElement(index$2.IconButton, { className: modalCloseButtonClass, label: closeButtonLabel, onClick: onRequestClose, "aria-label": closeButtonLabel, align: "left", ref: button }, /*#__PURE__*/React.createElement(iconsReact.Close, { size: 20, "aria-hidden": "true", tabIndex: "-1", className: `${modalCloseButtonClass}__icon` }))); // alertdialog is the only permitted aria role for a native dialog element // https://www.w3.org/TR/html-aria/#docconformance:~:text=Role%3A-,alertdialog,-.%20(dialog%20is const isAlertDialog = alert && !passiveModal; const modalBody = enableDialogElement ? /*#__PURE__*/React.createElement(Dialog.Dialog, { open: open, focusAfterCloseRef: launcherButtonRef, modal: true, ref: innerModal, role: isAlertDialog ? 'alertdialog' : '', "aria-describedby": isAlertDialog ? modalBodyId : '', className: containerClasses, "aria-label": ariaLabel, "data-exiting": presenceContext?.isExiting || undefined }, /*#__PURE__*/React.createElement("div", { className: `${prefix}--modal-header` }, modalLabel && /*#__PURE__*/React.createElement(Text.Text, { as: "h2", id: modalLabelId, className: `${prefix}--modal-header__label` }, modalLabel), /*#__PURE__*/React.createElement(Text.Text, { as: "h2", id: modalHeadingId, className: `${prefix}--modal-header__heading` }, modalHeading), decorator ? /*#__PURE__*/React.createElement("div", { className: `${prefix}--modal--inner__decorator` }, normalizedDecorator) : '', /*#__PURE__*/React.createElement("div", { className: `${prefix}--modal-close-button` }, /*#__PURE__*/React.createElement(index$2.IconButton, { className: modalCloseButtonClass, label: closeButtonLabel, onClick: onRequestClose, "aria-label": closeButtonLabel, align: "left", ref: button }, /*#__PURE__*/React.createElement(iconsReact.Close, { size: 20, "aria-hidden": "true", tabIndex: "-1", className: `${modalCloseButtonClass}__icon` })))), /*#__PURE__*/React.createElement(index$3.Layer, _rollupPluginBabelHelpers.extends({ ref: contentRef, id: modalBodyId, className: contentClasses }, hasScrollingContentProps), children), !passiveModal && /*#__PURE__*/React.createElement(ButtonSet.default, { className: footerClasses, "aria-busy": loadingActive }, Array.isArray(secondaryButtons) && secondaryButtons.length <= 2 ? secondaryButtons.map(({ buttonText, onClick: onButtonClick }, i) => /*#__PURE__*/React.createElement(Button.default, { key: `${buttonText}-${i}`, kind: "secondary", onClick: onButtonClick }, buttonText)) : secondaryButtonText && /*#__PURE__*/React.createElement(Button.default, { disabled: loadingActive, kind: "secondary", onClick: onSecondaryButtonClick, ref: secondaryButton }, secondaryButtonText), /*#__PURE__*/React.createElement(Button.default, { className: primaryButtonClass, kind: danger ? 'danger' : 'primary', disabled: loadingActive || primaryButtonDisabled, onClick: onRequestSubmit, ref: button }, loadingStatus === 'inactive' ? primaryButtonText : /*#__PURE__*/React.createElement(InlineLoading.default, { status: loadingStatus, description: loadingDescription, iconDescription: loadingIconDescription, className: `${prefix}--inline-loading--btn`, onSuccess: onLoadingSuccess })))) : /*#__PURE__*/React.createElement(React.Fragment, null, !enableDialogElement && !focusTrapWithoutSentinels && /*#__PURE__*/React.createElement("span", { ref: startTrap, tabIndex: 0, role: "link", className: `${prefix}--visually-hidden` }, "Focus sentinel"), /*#__PURE__*/React.createElement("div", _rollupPluginBabelHelpers.extends({ ref: innerModal, role: "dialog" }, alertDialogProps, { className: containerClasses, "aria-label": ariaLabel, "aria-modal": "true", tabIndex: -1 }), /*#__PURE__*/React.createElement("div", { className: `${prefix}--modal-header` }, passiveModal && modalButton, modalLabel && /*#__PURE__*/React.createElement(Text.Text, { as: "h2", id: modalLabelId, className: `${prefix}--modal-header__label` }, modalLabel), /*#__PURE__*/React.createElement(Text.Text, { as: "h2", id: modalHeadingId, className: `${prefix}--modal-header__heading` }, modalHeading), slug ? normalizedDecorator : decorator ? /*#__PURE__*/React.createElement("div", { className: `${prefix}--modal--inner__decorator` }, normalizedDecorator) : '', !passiveModal && modalButton), /*#__PURE__*/React.createElement(index$3.Layer, _rollupPluginBabelHelpers.extends({ ref: contentRef, id: modalBodyId, className: contentClasses }, hasScrollingContentProps), children), !passiveModal && /*#__PURE__*/React.createElement(ButtonSet.default, { className: footerClasses, "aria-busy": loadingActive }, Array.isArray(secondaryButtons) && secondaryButtons.length <= 2 ? secondaryButtons.map(({ buttonText, onClick: onButtonClick }, i) => /*#__PURE__*/React.createElement(Button.default, { key: `${buttonText}-${i}`, kind: "secondary", onClick: onButtonClick }, buttonText)) : secondaryButtonText && /*#__PURE__*/React.createElement(Button.default, { disabled: loadingActive, kind: "secondary", onClick: onSecondaryButtonClick, ref: secondaryButton }, secondaryButtonText), /*#__PURE__*/React.createElement(Button.default, { className: primaryButtonClass, kind: danger ? 'danger' : 'primary', disabled: loadingActive || primaryButtonDisabled, onClick: onRequestSubmit, ref: button }, loadingStatus === 'inactive' ? primaryButtonText : /*#__PURE__*/React.createElement(InlineLoading.default, { status: loadingStatus, description: loadingDescription, iconDescription: loadingIconDescription, className: `${prefix}--inline-loading--btn`, onSuccess: onLoadingSuccess })))), !enableDialogElement && !focusTrapWithoutSentinels && /*#__PURE__*/React.createElement("span", { ref: endTrap, tabIndex: 0, role: "link", className: `${prefix}--visually-hidden` }, "Focus sentinel")); return /*#__PURE__*/React.createElement(index$3.Layer, _rollupPluginBabelHelpers.extends({}, rest, { level: 0, onKeyDown: handleKeyDown, onClick: events.composeEventHandlers([rest?.onClick, handleOnClick]), onBlur: handleBlur, className: modalClasses, role: "presentation", ref: mergedRefs, "data-exiting": presenceContext?.isExiting || undefined }), modalBody); }); 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']: requiredIfGivenPropIsTruthy.requiredIfGivenPropIsTruthy('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 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, /** * **Experimental**: Provide a decorator component to be rendered inside the `Modal` component */ decorator: PropTypes.node, /** * 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.oneOfType([ // `PropTypes.instanceOf(HTMLButtonElement)` alone won't work because // `HTMLButtonElement` is not defined in the test environment even // though `testEnvironment` is set to `jsdom`. typeof HTMLButtonElement !== 'undefined' ? PropTypes.instanceOf(HTMLButtonElement) : PropTypes.any, PropTypes.oneOf([null])]).isRequired })]), /** * 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: (props, propName) => { if (!props.passiveModal && props[propName] === false) { return new Error(invalidOutsideClickMessage); } return null; }, /** * Specify whether the Button should be disabled, or not */ primaryButtonDisabled: PropTypes.bool, /** * Specify the text for the primary button */ primaryButtonText: PropTypes.node, /** * 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), slug: deprecate.deprecate(PropTypes.node, 'The `slug` prop has been deprecated and will be removed in the next major version. Use the decorator prop instead.') }; exports.ModalSizes = ModalSizes; exports.default = Modal;