@carbon/react
Version:
React components for the Carbon Design System
594 lines (569 loc) • 16.7 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.
*/
import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js';
import PropTypes from 'prop-types';
import React, { useRef, useEffect, createContext, useContext, useState } from 'react';
import useIsomorphicEffect from '../../internal/useIsomorphicEffect.js';
import { usePrefix } from '../../internal/usePrefix.js';
import cx from 'classnames';
import { Close } from '@carbon/icons-react';
import { IconButton } from '../IconButton/index.js';
import { noopFn } from '../../internal/noopFn.js';
import '../Text/index.js';
import { Layer } from '../Layer/index.js';
import ButtonSet from '../ButtonSet/ButtonSet.js';
import Button from '../Button/Button.js';
import '../Button/Button.Skeleton.js';
import { useId } from '../../internal/useId.js';
import InlineLoading from '../InlineLoading/InlineLoading.js';
import { debounce } from '../../node_modules/es-toolkit/dist/compat/function/debounce.js';
import { Text } from '../Text/Text.js';
const DialogContext = /*#__PURE__*/createContext({});
/**
* ----------
* Dialog
* ----------
*/
const Dialog = /*#__PURE__*/React.forwardRef(({
children,
className,
focusAfterCloseRef,
modal,
onCancel = noopFn,
onClick = noopFn,
onClose = noopFn,
onRequestClose = noopFn,
open = false,
role,
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
...rest
}, forwardRef) => {
const prefix = usePrefix();
const dialogId = useId();
const titleId = `${prefix}--dialog-header__heading--${dialogId}`;
const subtitleId = `${prefix}--dialog-header__label--${dialogId}`;
// This component needs access to a ref, placed on the dialog, to call the
// various imperative dialog functions (show(), close(), etc.).
// If the parent component has not passed a ref for forwardRef, forwardRef
// will be null. A "backup" ref is needed to ensure the dialog's instance
// methods can always be called within this component.
const backupRef = useRef(null);
const ref = forwardRef ?? backupRef;
// Clicks on the backdrop of an open modal dialog should request the consuming component to close
// the dialog. Clicks elsewhere, or on non-modal dialogs should not request
// to close the dialog.
function handleModalBackdropClick(e) {
if (open && modal && e.target === ref.current) {
onRequestClose(e);
}
}
function handleClick(e) {
handleModalBackdropClick(e);
// onClick should always be called, no matter if the target is a modal
// dialog, modal dialog backdrop, or non-modal dialog.
onClick(e);
}
useEffect(() => {
if (ref.current) {
if (open) {
if (modal) {
// Display the dialog as a modal, over the top of any other dialogs
// that might be present. Everything outside the dialog are inert
// with interactions outside the dialog being blocked.
ref.current.showModal();
} else {
// Display the dialog modelessly, i.e. still allowing interaction
// with content outside of the dialog.
ref.current.show();
}
} else {
ref.current.close();
}
}
}, [modal, open]);
useEffect(() => {
if (!open && focusAfterCloseRef) {
// use setTimeout to ensure focus is set after all other default focus behavior
const moveFocus = setTimeout(() => {
focusAfterCloseRef.current?.focus();
});
//component did unmount equivalent
return () => {
clearTimeout(moveFocus);
};
}
}, [open, focusAfterCloseRef]);
const containerClasses = cx(`${prefix}--dialog-container`);
const contextValue = {
dialogId,
titleId,
subtitleId,
isOpen: open
};
useEffect(() => {
if (ref.current && open && !ariaLabel && !ariaLabelledBy) {
const title = ref.current.querySelector(`.${prefix}--dialog-header__heading`);
// Set aria-labelledby to the title's ID if it exists
if (title && title.id) {
ref.current.setAttribute('aria-labelledby', title.id);
}
}
}, [open, ariaLabel, ariaLabelledBy, prefix]);
return /*#__PURE__*/React.createElement(DialogContext.Provider, {
value: contextValue
}, /*#__PURE__*/React.createElement("dialog", _extends({}, rest, {
className: cx(`${prefix}--dialog`, {
[`${prefix}--dialog--modal`]: modal
}, className),
ref: ref,
onCancel: onCancel,
onClick: handleClick,
onClose: onClose,
role: role,
"aria-label": ariaLabel,
"aria-labelledby": !ariaLabel ? ariaLabelledBy || titleId : undefined,
"aria-describedby": ariaDescribedBy
}), /*#__PURE__*/React.createElement("div", {
className: containerClasses
}, children)));
});
Dialog.displayName = 'Dialog';
Dialog.propTypes = {
/**
* Provide children to be rendered inside of the Dialog
*/
children: PropTypes.node,
/**
* Specify an optional className to be applied to the modal root node
*/
className: PropTypes.string,
/**
* Provide a ref to return focus to once the dialog is closed.
*/
focusAfterCloseRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({
current: PropTypes.any
})]),
/**
* Modal specifies whether the Dialog is modal or non-modal. This cannot be
* changed while open=true
*/
modal: PropTypes.bool,
/**
* Specify a handler for closing Dialog.
* The handler should care of closing Dialog, e.g. changing `open` prop.
*/
onRequestClose: PropTypes.func,
/**
* open initial state
*/
open: PropTypes.bool,
/**
* Specify the role of the dialog for accessibility
*/
role: PropTypes.oneOf(['dialog', 'alertdialog']),
/**
* Specify a label for screen readers
*/
'aria-label': PropTypes.string,
/**
* Specify the ID of an element that labels this dialog
*/
'aria-labelledby': PropTypes.string,
/**
* Specify the ID of an element that describes this dialog
*/
ariaDescribedBy: PropTypes.string
};
/**
* -------------
* DialogHeader
* -------------
*/
const DialogHeader = /*#__PURE__*/React.forwardRef(({
children,
...rest
}, ref) => {
const prefix = usePrefix();
return /*#__PURE__*/React.createElement("div", _extends({
className: `${prefix}--dialog__header`,
ref: ref
}, rest), children);
});
DialogHeader.displayName = 'DialogHeader';
DialogHeader.propTypes = {
/**
* Provide the contents to be rendered inside of this component
*/
children: PropTypes.node
};
/**
* ---------------
* DialogControls
* ---------------
*/
const DialogControls = /*#__PURE__*/React.forwardRef(({
children,
...rest
}, ref) => {
const prefix = usePrefix();
return (
/*#__PURE__*/
// @ts-ignore
React.createElement("div", _extends({
className: `${prefix}--dialog__header-controls`,
ref: ref
}, rest), children)
);
});
DialogControls.displayName = 'DialogControls';
DialogControls.propTypes = {
/**
* Provide children to be rendered inside of this component
*/
children: PropTypes.node
};
/**
* -------------------
* DialogCloseButton
* -------------------
*/
const DialogCloseButton = /*#__PURE__*/React.forwardRef(({
onClick,
...rest
}, ref) => {
const prefix = usePrefix();
return (
/*#__PURE__*/
// @ts-ignore
React.createElement(IconButton, _extends({
kind: "ghost",
className: `${prefix}--dialog__close`,
label: "Close",
title: "Close",
"aria-label": "Close",
align: "left",
onClick: onClick,
ref: ref
}, rest), /*#__PURE__*/React.createElement(Close, {
size: 20,
"aria-hidden": "true",
tabIndex: -1,
className: `${prefix}--icon__close`
}))
);
});
DialogCloseButton.displayName = 'DialogCloseButton';
DialogCloseButton.propTypes = {
/**
* Specify a click handler applied to the IconButton
*/
onClick: PropTypes.func
};
/**
* ------------
* DialogTitle
* ------------
*/
const DialogTitle = /*#__PURE__*/React.forwardRef(({
children,
className,
id,
...rest
}, ref) => {
const prefix = usePrefix();
const {
titleId
} = useContext(DialogContext);
const headingId = id || titleId;
return /*#__PURE__*/React.createElement(Text, _extends({
as: "h2",
id: headingId,
className: cx(`${prefix}--dialog-header__heading`, className),
ref: ref
}, rest), children);
});
DialogTitle.displayName = 'DialogTitle';
DialogTitle.propTypes = {
/**
* Provide the contents to be rendered inside of this component
*/
children: PropTypes.node,
/**
* Specify an optional className to be applied to the title node
*/
className: PropTypes.string,
/**
* Specify an optional id for the title element
*/
id: PropTypes.string
};
/**
* ---------------
* DialogSubtitle
* ---------------
*/
const DialogSubtitle = /*#__PURE__*/React.forwardRef(({
children,
className,
id,
...rest
}, ref) => {
const prefix = usePrefix();
const {
subtitleId
} = useContext(DialogContext);
const labelId = id || subtitleId;
return /*#__PURE__*/React.createElement(Text, _extends({
as: "h2",
id: labelId,
className: cx(`${prefix}--dialog-header__label`, className),
ref: ref
}, rest), children);
});
DialogSubtitle.displayName = 'DialogSubtitle';
DialogSubtitle.propTypes = {
/**
* Provide the contents to be rendered inside of this component
*/
children: PropTypes.node,
/**
* Specify an optional className to be applied to the subtitle node
*/
className: PropTypes.string,
/**
* Specify an optional id for the subtitle element
*/
id: PropTypes.string
};
/**
* -----------
* DialogBody
* -----------
*/
const DialogBody = /*#__PURE__*/React.forwardRef(({
children,
className,
hasScrollingContent,
...rest
}, ref) => {
const prefix = usePrefix();
const contentRef = useRef(null);
const [isScrollable, setIsScrollable] = useState(false);
const dialogId = useId();
const dialogBodyId = `${prefix}--dialog-body--${dialogId}`;
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);
};
}, []);
const contentClasses = cx(`${prefix}--dialog-content`, {
[`${prefix}--dialog-scroll-content`]: hasScrollingContent || isScrollable
}, className);
const hasScrollingContentProps = hasScrollingContent || isScrollable ? {
tabIndex: 0,
role: 'region'
} : {};
const combinedRef = el => {
if (typeof ref === 'function') {
ref(el);
} else if (ref) {
ref.current = el;
}
contentRef.current = el;
};
return /*#__PURE__*/React.createElement(Layer, _extends({
ref: combinedRef,
id: dialogBodyId,
className: contentClasses
}, hasScrollingContentProps, rest), children);
});
DialogBody.displayName = 'DialogBody';
DialogBody.propTypes = {
/**
* Provide the contents to be rendered inside of this component
*/
children: PropTypes.node,
/**
* Specify an optional className to be applied to the body node
*/
className: PropTypes.string,
/**
* Specify whether the content has overflow that should be scrollable
*/
hasScrollingContent: PropTypes.bool
};
/**
* -------------
* DialogFooter
* -------------
*/
const DialogFooter = /*#__PURE__*/React.forwardRef(({
children,
className,
onRequestClose = noopFn,
onSecondarySubmit,
onRequestSubmit = noopFn,
primaryButtonText = 'Save',
primaryButtonDisabled = false,
secondaryButtonText = 'Cancel',
secondaryButtons,
loadingStatus = 'inactive',
loadingDescription,
loadingIconDescription,
onLoadingSuccess = noopFn,
danger = false,
...rest
}, ref) => {
const prefix = usePrefix();
const button = useRef(null);
const {
isOpen
} = useContext(DialogContext);
const [secondaryButtonRef, setSecondaryButtonRef] = useState(null);
useEffect(() => {
if (danger && secondaryButtonRef) {
const focusFrame = requestAnimationFrame(() => {
secondaryButtonRef.focus();
});
return () => cancelAnimationFrame(focusFrame);
}
}, [danger, secondaryButtonRef, isOpen]);
const classes = cx(`${prefix}--dialog-footer`, className, {
[`${prefix}--dialog-footer--three-button`]: Array.isArray(secondaryButtons) && secondaryButtons.length === 2
});
const loadingActive = loadingStatus !== 'inactive';
const primaryButtonClass = cx({
[`${prefix}--btn--loading`]: loadingStatus !== 'inactive'
});
const onSecondaryButtonClick = onSecondarySubmit ? onSecondarySubmit : onRequestClose;
if (children) {
return /*#__PURE__*/React.createElement(ButtonSet, _extends({
className: classes,
ref: ref
}, rest), children);
}
return /*#__PURE__*/React.createElement(ButtonSet, _extends({
className: classes,
"aria-busy": loadingActive,
ref: ref
}, rest), Array.isArray(secondaryButtons) && secondaryButtons.length <= 2 ? secondaryButtons.map(({
buttonText,
onClick: onButtonClick
}, i) => /*#__PURE__*/React.createElement(Button, {
key: `${buttonText}-${i}`,
autoFocus: danger,
kind: "secondary",
ref: i === 0 && danger ? setSecondaryButtonRef : undefined,
onClick: onButtonClick
}, buttonText)) : secondaryButtonText && /*#__PURE__*/React.createElement(Button, {
ref: danger ? setSecondaryButtonRef : undefined,
disabled: loadingActive,
kind: "secondary",
autoFocus: danger,
onClick: onSecondaryButtonClick
}, secondaryButtonText), /*#__PURE__*/React.createElement(Button, {
className: primaryButtonClass,
kind: danger ? 'danger' : 'primary',
disabled: loadingActive || primaryButtonDisabled,
onClick: onRequestSubmit,
ref: button
}, loadingStatus === 'inactive' ? primaryButtonText : /*#__PURE__*/React.createElement(InlineLoading, {
status: loadingStatus,
description: loadingDescription,
iconDescription: loadingIconDescription,
className: `${prefix}--inline-loading--btn`,
onSuccess: onLoadingSuccess
})));
});
DialogFooter.displayName = 'DialogFooter';
DialogFooter.propTypes = {
/**
* Provide the contents to be rendered inside of this component
*/
children: PropTypes.node,
/**
* Specify an optional className to be applied to the footer node
*/
className: PropTypes.string,
/**
* Specify a handler for closing dialog.
*/
onRequestClose: PropTypes.func,
/**
* Specify a handler for the secondary button.
*/
onSecondarySubmit: PropTypes.func,
/**
* Specify a handler for submitting dialog.
*/
onRequestSubmit: PropTypes.func,
/**
* Specify the text for the primary button
*/
primaryButtonText: PropTypes.node,
/**
* Specify whether the Button should be disabled, or not
*/
primaryButtonDisabled: PropTypes.bool,
/**
* Specify the text for the secondary button
*/
secondaryButtonText: PropTypes.node,
/**
* Specify an array of config objects for secondary buttons
*/
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 whether the Dialog is for dangerous actions
*/
danger: PropTypes.bool,
/**
* Specify loading status
*/
loadingStatus: PropTypes.oneOf(['inactive', 'active', 'finished', 'error']),
/**
* Specify the description for the loading text
*/
loadingDescription: PropTypes.string,
/**
* Specify the description for the loading icon
*/
loadingIconDescription: PropTypes.string,
/**
* Provide an optional handler to be invoked when loading is
* successful
*/
onLoadingSuccess: PropTypes.func
};
export { Dialog, DialogBody, DialogCloseButton, DialogControls, DialogFooter, DialogHeader, DialogSubtitle, DialogTitle };