@carbon/react
Version:
React components for the Carbon Design System
348 lines (346 loc) • 12.7 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 2026
*
* 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 { usePrefix } from "../../internal/usePrefix.js";
import { Text } from "../Text/Text.js";
import { useId } from "../../internal/useId.js";
import { noopFn } from "../../internal/noopFn.js";
import { deprecate } from "../../prop-types/deprecate.js";
import { IconButton } from "../IconButton/index.js";
import Button_default from "../Button/index.js";
import ButtonSet_default from "../ButtonSet/index.js";
import { useResizeObserver } from "../../internal/useResizeObserver.js";
import { Layer } from "../Layer/index.js";
import InlineLoading from "../InlineLoading/InlineLoading.js";
import classNames from "classnames";
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { jsx, jsxs } from "react/jsx-runtime";
import { Close } from "@carbon/icons-react";
//#region src/components/Dialog/Dialog.tsx
/**
* Copyright IBM Corp. 2025, 2026
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
const DialogContext = createContext({});
const Dialog = React.forwardRef(({ children, className, focusAfterCloseRef, modal, onCancel = noopFn, onClick = noopFn, onClose = noopFn, onRequestClose = noopFn, open = false, role, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ariaLabel: deprecatedAriaLabel, ariaLabelledBy: deprecatedAriaLabelledBy, ariaDescribedBy: deprecatedAriaDescribedBy, ...rest }, forwardRef) => {
const prefix = usePrefix();
const dialogId = useId();
const [titleId, setTitleId] = useState(void 0);
const [subtitleId, setSubtitleId] = useState(void 0);
const backupRef = useRef(null);
const ref = forwardRef ?? backupRef;
function handleModalBackdropClick(e) {
if (open && modal && e.target === ref.current) onRequestClose(e);
}
function handleClick(e) {
handleModalBackdropClick(e);
onClick(e);
}
useEffect(() => {
if (ref.current) if (open) if (modal) ref.current.showModal();
else ref.current.show();
else ref.current.close();
}, [modal, open]);
useEffect(() => {
if (!open && focusAfterCloseRef) {
const moveFocus = setTimeout(() => {
focusAfterCloseRef.current?.focus();
});
return () => {
clearTimeout(moveFocus);
};
}
}, [open, focusAfterCloseRef]);
const containerClasses = classNames(`${prefix}--dialog-container`);
const contextValue = {
dialogId,
titleId,
subtitleId,
isOpen: open,
setTitleId,
setSubtitleId
};
useEffect(() => {
const effectiveAriaLabel = ariaLabel || deprecatedAriaLabel;
const effectiveAriaLabelledBy = ariaLabelledBy || deprecatedAriaLabelledBy;
if (ref.current && open && !effectiveAriaLabel && !effectiveAriaLabelledBy) {
const title = ref.current.querySelector(`.${prefix}--dialog-header__heading`);
if (title && title.id) ref.current.setAttribute("aria-labelledby", title.id);
}
}, [
open,
ariaLabel,
deprecatedAriaLabel,
ariaLabelledBy,
deprecatedAriaLabelledBy,
prefix
]);
return /* @__PURE__ */ jsx(DialogContext.Provider, {
value: contextValue,
children: /* @__PURE__ */ jsx("dialog", {
...rest,
className: classNames(`${prefix}--dialog`, { [`${prefix}--dialog--modal`]: modal }, className),
ref,
onCancel,
onClick: handleClick,
onClose,
role,
"aria-label": ariaLabel || deprecatedAriaLabel,
"aria-labelledby": !(ariaLabel || deprecatedAriaLabel) ? ariaLabelledBy || deprecatedAriaLabelledBy || titleId : void 0,
"aria-describedby": ariaDescribedBy || deprecatedAriaDescribedBy,
children: /* @__PURE__ */ jsx("div", {
className: containerClasses,
children
})
})
});
});
Dialog.displayName = "Dialog";
Dialog.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
focusAfterCloseRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.any })]),
modal: PropTypes.bool,
onRequestClose: PropTypes.func,
open: PropTypes.bool,
role: PropTypes.oneOf(["dialog", "alertdialog"]),
"aria-label": PropTypes.string,
"aria-labelledby": PropTypes.string,
"aria-describedby": PropTypes.string,
ariaLabel: deprecate(PropTypes.string, "This prop syntax has been deprecated. Please use the new `aria-label`"),
ariaLabelledBy: deprecate(PropTypes.string, "This prop syntax has been deprecated. Please use the new `aria-labelledby`"),
ariaDescribedBy: deprecate(PropTypes.string, "This prop syntax has been deprecated. Please use the new `aria-describedby`")
};
const DialogHeader = React.forwardRef(({ children, ...rest }, ref) => {
return /* @__PURE__ */ jsx("div", {
className: `${usePrefix()}--dialog__header`,
ref,
...rest,
children
});
});
DialogHeader.displayName = "DialogHeader";
DialogHeader.propTypes = { children: PropTypes.node };
const DialogControls = React.forwardRef(({ children, ...rest }, ref) => {
return /* @__PURE__ */ jsx("div", {
className: `${usePrefix()}--dialog__header-controls`,
ref,
...rest,
children
});
});
DialogControls.displayName = "DialogControls";
DialogControls.propTypes = { children: PropTypes.node };
const DialogCloseButton = React.forwardRef(({ onClick, ...rest }, ref) => {
const prefix = usePrefix();
return /* @__PURE__ */ jsx(IconButton, {
kind: "ghost",
className: `${prefix}--dialog__close`,
label: "Close",
title: "Close",
"aria-label": "Close",
align: "left",
onClick,
ref,
...rest,
children: /* @__PURE__ */ jsx(Close, {
size: 20,
"aria-hidden": "true",
tabIndex: -1,
className: `${prefix}--icon__close`
})
});
});
DialogCloseButton.displayName = "DialogCloseButton";
DialogCloseButton.propTypes = { onClick: PropTypes.func };
const DialogTitle = React.forwardRef(({ children, className, id, ...rest }, ref) => {
const prefix = usePrefix();
const { dialogId, setTitleId } = useContext(DialogContext);
const defaultTitleId = `${prefix}--dialog-header__heading--${dialogId}`;
const headingId = id ?? defaultTitleId;
useEffect(() => {
setTitleId?.(headingId);
return () => setTitleId?.(void 0);
}, [headingId, setTitleId]);
return /* @__PURE__ */ jsx(Text, {
as: "h2",
id: headingId,
className: classNames(`${prefix}--dialog-header__heading`, className),
ref,
...rest,
children
});
});
DialogTitle.displayName = "DialogTitle";
DialogTitle.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
id: PropTypes.string
};
const DialogSubtitle = React.forwardRef(({ children, className, id, ...rest }, ref) => {
const prefix = usePrefix();
const { dialogId, setSubtitleId } = useContext(DialogContext);
const defaultSubtitleId = `${prefix}--dialog-header__label--${dialogId}`;
const labelId = id ?? defaultSubtitleId;
useEffect(() => {
setSubtitleId?.(labelId);
return () => setSubtitleId?.(void 0);
}, [labelId, setSubtitleId]);
return /* @__PURE__ */ jsx(Text, {
as: "h2",
id: labelId,
className: classNames(`${prefix}--dialog-header__label`, className),
ref,
...rest,
children
});
});
DialogSubtitle.displayName = "DialogSubtitle";
DialogSubtitle.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
id: PropTypes.string
};
const DialogBody = React.forwardRef(({ children, className, hasScrollingContent, ...rest }, ref) => {
const prefix = usePrefix();
const contentRef = useRef(null);
const dialogBodyId = `${prefix}--dialog-body--${useId()}`;
const { titleId, subtitleId } = useContext(DialogContext);
const { height } = 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 contentClasses = classNames(`${prefix}--dialog-content`, {
[`${prefix}--dialog-scroll-content`]: hasScrollingContent || isScrollable,
[`${prefix}--dialog-scroll-content--no-fade`]: height <= 300
}, className);
const hasScrollingContentProps = hasScrollingContent || isScrollable ? {
tabIndex: 0,
role: "region",
"aria-labelledby": subtitleId ?? titleId
} : {};
const combinedRef = (el) => {
if (typeof ref === "function") ref(el);
else if (ref) ref.current = el;
contentRef.current = el;
};
return /* @__PURE__ */ jsx(Layer, {
ref: combinedRef,
id: dialogBodyId,
className: contentClasses,
...hasScrollingContentProps,
...rest,
children
});
});
DialogBody.displayName = "DialogBody";
DialogBody.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
hasScrollingContent: PropTypes.bool
};
const DialogFooter = 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 = classNames(`${prefix}--dialog-footer`, className, { [`${prefix}--dialog-footer--three-button`]: Array.isArray(secondaryButtons) && secondaryButtons.length === 2 });
const loadingActive = loadingStatus !== "inactive";
const primaryButtonClass = classNames({ [`${prefix}--btn--loading`]: loadingStatus !== "inactive" });
const onSecondaryButtonClick = onSecondarySubmit ? onSecondarySubmit : onRequestClose;
if (children) return /* @__PURE__ */ jsx(ButtonSet_default, {
className: classes,
ref,
...rest,
children
});
return /* @__PURE__ */ jsxs(ButtonSet_default, {
className: classes,
"aria-busy": loadingActive,
ref,
...rest,
children: [Array.isArray(secondaryButtons) && secondaryButtons.length <= 2 ? secondaryButtons.map(({ buttonText, onClick: onButtonClick }, i) => /* @__PURE__ */ jsx(Button_default, {
autoFocus: danger,
kind: "secondary",
ref: i === 0 && danger ? setSecondaryButtonRef : void 0,
onClick: onButtonClick,
children: buttonText
}, `${buttonText}-${i}`)) : secondaryButtonText && /* @__PURE__ */ jsx(Button_default, {
ref: danger ? setSecondaryButtonRef : void 0,
disabled: loadingActive,
kind: "secondary",
autoFocus: danger,
onClick: onSecondaryButtonClick,
children: secondaryButtonText
}), /* @__PURE__ */ jsx(Button_default, {
className: primaryButtonClass,
kind: danger ? "danger" : "primary",
disabled: loadingActive || primaryButtonDisabled,
onClick: onRequestSubmit,
ref: button,
children: loadingStatus === "inactive" ? primaryButtonText : /* @__PURE__ */ jsx(InlineLoading, {
status: loadingStatus,
description: loadingDescription,
iconDescription: loadingIconDescription,
className: `${prefix}--inline-loading--btn`,
onSuccess: onLoadingSuccess
})
})]
});
});
DialogFooter.displayName = "DialogFooter";
DialogFooter.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
onRequestClose: PropTypes.func,
onSecondarySubmit: PropTypes.func,
onRequestSubmit: PropTypes.func,
primaryButtonText: PropTypes.node,
primaryButtonDisabled: PropTypes.bool,
secondaryButtonText: PropTypes.node,
secondaryButtons: (props, propName, componentName) => {
if (props.secondaryButtons) {
if (!Array.isArray(props.secondaryButtons) || props.secondaryButtons.length !== 2) return /* @__PURE__ */ 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;
},
danger: PropTypes.bool,
loadingStatus: PropTypes.oneOf([
"inactive",
"active",
"finished",
"error"
]),
loadingDescription: PropTypes.string,
loadingIconDescription: PropTypes.string,
onLoadingSuccess: PropTypes.func
};
//#endregion
export { Dialog, DialogBody, DialogCloseButton, DialogControls, DialogFooter, DialogHeader, DialogSubtitle, DialogTitle };