UNPKG

@carbon/react

Version:

React components for the Carbon Design System

348 lines (346 loc) 12.7 kB
/** * 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 };