UNPKG

@carbon/react

Version:

React components for the Carbon Design System

547 lines (545 loc) 19.4 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 { Escape, Tab } from "../../internal/keyboard/keys.js"; import { match, matches } from "../../internal/keyboard/match.js"; import useIsomorphicEffect from "../../internal/useIsomorphicEffect.js"; import { useId } from "../../internal/useId.js"; import { noopFn } from "../../internal/noopFn.js"; import { warning } from "../../internal/warning.js"; import { deprecate } from "../../prop-types/deprecate.js"; import { deprecateValuesWithin } from "../../prop-types/deprecateValuesWithin.js"; import { useFeatureFlag } from "../FeatureFlags/index.js"; import { useInteractiveChildrenNeedDescription, useNoInteractiveChildren } from "../../internal/useNoInteractiveChildren.js"; import Button_default from "../Button/index.js"; import { wrapFocus, wrapFocusWithoutSentinels } from "../../internal/wrapFocus.js"; import classNames from "classnames"; import { useEffect, useRef, useState } from "react"; import PropTypes from "prop-types"; import { jsx, jsxs } from "react/jsx-runtime"; import { CheckmarkFilled, Close, ErrorFilled, InformationFilled, InformationSquareFilled, WarningAltFilled, WarningFilled } from "@carbon/icons-react"; //#region src/components/Notification/Notification.tsx /** * 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. */ /** * Conditionally call a callback when the escape key is pressed * @param {node} ref - ref of the container element to scope the functionality to * @param {func} callback - function to be called * @param {bool} override - escape hatch to conditionally call the callback */ function useEscapeToClose(ref, callback, override = true) { const handleKeyDown = (event) => { const elementContainsFocus = ref.current && document.activeElement === ref.current || ref.current?.contains(document.activeElement); if (matches(event, [Escape]) && override && elementContainsFocus) callback(event); }; useIsomorphicEffect(() => { if (ref.current !== null) document.addEventListener("keydown", handleKeyDown, false); return () => document.removeEventListener("keydown", handleKeyDown, false); }); } function NotificationActionButton({ children, className: customClassName, onClick, inline, ...rest }) { return /* @__PURE__ */ jsx(Button_default, { className: classNames(customClassName, { [`${usePrefix()}--actionable-notification__action-button`]: true }), kind: inline ? "ghost" : "tertiary", onClick, size: "sm", ...rest, children }); } NotificationActionButton.propTypes = { children: PropTypes.node, className: PropTypes.string, inline: PropTypes.bool, onClick: PropTypes.func }; function NotificationButton({ "aria-label": ariaLabel = "close notification", ariaLabel: deprecatedAriaLabel, className, type = "button", renderIcon: IconTag = Close, name, notificationType = "toast", ...rest }) { const prefix = usePrefix(); const buttonClassName = classNames(className, { [`${prefix}--${notificationType}-notification__close-button`]: notificationType }); const iconClassName = classNames({ [`${prefix}--${notificationType}-notification__close-icon`]: notificationType }); return /* @__PURE__ */ jsx("button", { ...rest, type, "aria-label": deprecatedAriaLabel || ariaLabel, title: deprecatedAriaLabel || ariaLabel, className: buttonClassName, children: IconTag && /* @__PURE__ */ jsx(IconTag, { className: iconClassName, name }) }); } NotificationButton.propTypes = { ["aria-label"]: PropTypes.string, ariaLabel: deprecate(PropTypes.string, "This prop syntax has been deprecated. Please use the new `aria-label`."), className: PropTypes.string, name: PropTypes.string, notificationType: PropTypes.oneOf([ "toast", "inline", "actionable" ]), renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), type: PropTypes.string }; /** * NotificationIcon * ================ */ const iconTypes = { error: ErrorFilled, success: CheckmarkFilled, warning: WarningFilled, ["warning-alt"]: WarningAltFilled, info: InformationFilled, ["info-square"]: InformationSquareFilled }; function NotificationIcon({ iconDescription, kind, notificationType }) { const prefix = usePrefix(); const IconForKind = iconTypes[kind]; if (!IconForKind) return null; return /* @__PURE__ */ jsx(IconForKind, { className: `${prefix}--${notificationType}-notification__icon`, size: 20, children: /* @__PURE__ */ jsx("title", { children: iconDescription }) }); } NotificationIcon.propTypes = { iconDescription: PropTypes.string.isRequired, kind: PropTypes.oneOf([ "error", "success", "warning", "warning-alt", "info", "info-square" ]).isRequired, notificationType: PropTypes.oneOf(["inline", "toast"]).isRequired }; function ToastNotification({ ["aria-label"]: ariaLabel, ariaLabel: deprecatedAriaLabel, role = "status", onClose, onCloseButtonClick = noopFn, statusIconDescription, className, children, kind = "error", lowContrast, hideCloseButton = false, timeout = 0, title, caption, subtitle, ...rest }) { const [isOpen, setIsOpen] = useState(true); const prefix = usePrefix(); const containerClassName = classNames(className, { [`${prefix}--toast-notification`]: true, [`${prefix}--toast-notification--low-contrast`]: lowContrast, [`${prefix}--toast-notification--${kind}`]: kind }); const contentRef = useRef(null); useNoInteractiveChildren(contentRef); const handleClose = (evt) => { if (!onClose || onClose(evt) !== false) setIsOpen(false); }; const ref = useRef(null); function handleCloseButtonClick(event) { onCloseButtonClick(event); handleClose(event); } const savedOnClose = useRef(onClose); useEffect(() => { savedOnClose.current = onClose; }); useEffect(() => { if (!timeout) return; const timeoutId = window.setTimeout((event) => { setIsOpen(false); if (savedOnClose.current) savedOnClose.current(event); }, timeout); return () => { window.clearTimeout(timeoutId); }; }, [timeout]); if (!isOpen) return null; return /* @__PURE__ */ jsxs("div", { ref, ...rest, role, className: containerClassName, children: [ /* @__PURE__ */ jsx(NotificationIcon, { notificationType: "toast", kind, iconDescription: statusIconDescription || `${kind} icon` }), /* @__PURE__ */ jsxs("div", { ref: contentRef, className: `${prefix}--toast-notification__details`, children: [ title && /* @__PURE__ */ jsx(Text, { as: "div", className: `${prefix}--toast-notification__title`, children: title }), subtitle && /* @__PURE__ */ jsx(Text, { as: "div", className: `${prefix}--toast-notification__subtitle`, children: subtitle }), caption && /* @__PURE__ */ jsx(Text, { as: "div", className: `${prefix}--toast-notification__caption`, children: caption }), children ] }), !hideCloseButton && /* @__PURE__ */ jsx(NotificationButton, { notificationType: "toast", onClick: handleCloseButtonClick, "aria-label": deprecatedAriaLabel || ariaLabel }) ] }); } ToastNotification.propTypes = { ["aria-label"]: PropTypes.string, ariaLabel: deprecate(PropTypes.string, "This prop syntax has been deprecated. Please use the new `aria-label`."), caption: PropTypes.string, children: PropTypes.node, className: PropTypes.string, hideCloseButton: PropTypes.bool, kind: PropTypes.oneOf([ "error", "info", "info-square", "success", "warning", "warning-alt" ]), lowContrast: PropTypes.bool, onClose: PropTypes.func, onCloseButtonClick: PropTypes.func, role: PropTypes.oneOf([ "alert", "log", "status" ]), statusIconDescription: PropTypes.string, subtitle: PropTypes.string, timeout: PropTypes.number, title: PropTypes.string }; function InlineNotification({ ["aria-label"]: ariaLabel, children, title, subtitle, role = "status", onClose, onCloseButtonClick = noopFn, statusIconDescription, className, kind = "error", lowContrast, hideCloseButton = false, ...rest }) { const [isOpen, setIsOpen] = useState(true); const prefix = usePrefix(); const containerClassName = classNames(className, { [`${prefix}--inline-notification`]: true, [`${prefix}--inline-notification--low-contrast`]: lowContrast, [`${prefix}--inline-notification--${kind}`]: kind, [`${prefix}--inline-notification--hide-close-button`]: hideCloseButton }); const contentRef = useRef(null); useNoInteractiveChildren(contentRef); const handleClose = (evt) => { if (!onClose || onClose(evt) !== false) setIsOpen(false); }; const ref = useRef(null); function handleCloseButtonClick(event) { onCloseButtonClick(event); handleClose(event); } if (!isOpen) return null; return /* @__PURE__ */ jsxs("div", { ref, ...rest, role, className: containerClassName, children: [/* @__PURE__ */ jsxs("div", { className: `${prefix}--inline-notification__details`, children: [/* @__PURE__ */ jsx(NotificationIcon, { notificationType: "inline", kind, iconDescription: statusIconDescription || `${kind} icon` }), /* @__PURE__ */ jsxs("div", { ref: contentRef, className: `${prefix}--inline-notification__text-wrapper`, children: [ title && /* @__PURE__ */ jsx(Text, { as: "div", className: `${prefix}--inline-notification__title`, children: title }), subtitle && /* @__PURE__ */ jsx(Text, { as: "div", className: `${prefix}--inline-notification__subtitle`, children: subtitle }), children ] })] }), !hideCloseButton && /* @__PURE__ */ jsx(NotificationButton, { notificationType: "inline", onClick: handleCloseButtonClick, "aria-label": ariaLabel })] }); } InlineNotification.propTypes = { ["aria-label"]: PropTypes.string, children: PropTypes.node, className: PropTypes.string, hideCloseButton: PropTypes.bool, kind: PropTypes.oneOf([ "error", "info", "info-square", "success", "warning", "warning-alt" ]), lowContrast: PropTypes.bool, onClose: PropTypes.func, onCloseButtonClick: PropTypes.func, role: PropTypes.oneOf([ "alert", "log", "status" ]), statusIconDescription: PropTypes.string, subtitle: PropTypes.string, title: PropTypes.string }; function ActionableNotification({ actionButtonLabel, ["aria-label"]: ariaLabel, ariaLabel: deprecatedAriaLabel, caption, children, role = "alertdialog", onActionButtonClick, onClose, onCloseButtonClick = noopFn, statusIconDescription, className, inline = false, kind = "error", lowContrast, hideCloseButton = false, hasFocus = true, closeOnEscape = true, title, subtitle, ...rest }) { const [isOpen, setIsOpen] = useState(true); const prefix = usePrefix(); const id = useId("actionable-notification"); const subtitleId = useId("actionable-notification-subtitle"); const containerClassName = classNames(className, { [`${prefix}--actionable-notification`]: true, [`${prefix}--actionable-notification--toast`]: !inline, [`${prefix}--actionable-notification--low-contrast`]: lowContrast, [`${prefix}--actionable-notification--${kind}`]: kind, [`${prefix}--actionable-notification--hide-close-button`]: hideCloseButton }); const innerModal = useRef(null); const startTrap = useRef(null); const endTrap = useRef(null); const ref = useRef(null); const deprecatedFlag = useFeatureFlag("enable-experimental-focus-wrap-without-sentinels"); const focusTrapWithoutSentinels = useFeatureFlag("enable-focus-wrap-without-sentinels") || deprecatedFlag; useIsomorphicEffect(() => { if (hasFocus && role === "alertdialog") document.querySelector(`button.${prefix}--actionable-notification__action-button`)?.focus(); }); function handleBlur({ target: oldActiveNode, relatedTarget: currentActiveNode }) { if (isOpen && currentActiveNode && oldActiveNode && role === "alertdialog") { const { current: bodyNode } = innerModal; const { current: startTrapNode } = startTrap; const { current: endTrapNode } = endTrap; wrapFocus({ bodyNode, startTrapNode, endTrapNode, currentActiveNode, oldActiveNode, prefix }); } } function handleKeyDown(event) { if (isOpen && match(event, Tab) && ref.current && role === "alertdialog") wrapFocusWithoutSentinels({ containerNode: ref.current, currentActiveNode: event.target, event }); } const handleClose = (evt) => { if (!onClose || onClose(evt) !== false) setIsOpen(false); }; useEscapeToClose(ref, handleCloseButtonClick, closeOnEscape); function handleCloseButtonClick(event) { onCloseButtonClick(event); handleClose(event); } if (!isOpen) return null; return /* @__PURE__ */ jsxs("div", { ...rest, ref, role, className: containerClassName, "aria-labelledby": title ? id : subtitleId, onBlur: !focusTrapWithoutSentinels ? handleBlur : () => {}, onKeyDown: focusTrapWithoutSentinels ? handleKeyDown : () => {}, children: [ !focusTrapWithoutSentinels && /* @__PURE__ */ jsx("span", { ref: startTrap, tabIndex: 0, role: "link", className: `${prefix}--visually-hidden`, children: "Focus sentinel" }), /* @__PURE__ */ jsxs("div", { ref: innerModal, className: `${prefix}--actionable-notification__focus-wrapper`, children: [/* @__PURE__ */ jsxs("div", { className: `${prefix}--actionable-notification__details`, children: [/* @__PURE__ */ jsx(NotificationIcon, { notificationType: inline ? "inline" : "toast", kind, iconDescription: statusIconDescription || `${kind} icon` }), /* @__PURE__ */ jsx("div", { className: `${prefix}--actionable-notification__text-wrapper`, children: /* @__PURE__ */ jsxs("div", { className: `${prefix}--actionable-notification__content`, children: [ title && /* @__PURE__ */ jsx(Text, { as: "div", className: `${prefix}--actionable-notification__title`, id, children: title }), subtitle && /* @__PURE__ */ jsx(Text, { as: "div", className: `${prefix}--actionable-notification__subtitle`, id: subtitleId, children: subtitle }), caption && /* @__PURE__ */ jsx(Text, { as: "div", className: `${prefix}--actionable-notification__caption`, children: caption }), children ] }) })] }), /* @__PURE__ */ jsxs("div", { className: `${prefix}--actionable-notification__button-wrapper`, children: [actionButtonLabel && /* @__PURE__ */ jsx(NotificationActionButton, { onClick: onActionButtonClick, inline, children: actionButtonLabel }), !hideCloseButton && /* @__PURE__ */ jsx(NotificationButton, { "aria-label": deprecatedAriaLabel || ariaLabel, notificationType: "actionable", onClick: handleCloseButtonClick })] })] }), !focusTrapWithoutSentinels && /* @__PURE__ */ jsx("span", { ref: endTrap, tabIndex: 0, role: "link", className: `${prefix}--visually-hidden`, children: "Focus sentinel" }) ] }); } ActionableNotification.propTypes = { actionButtonLabel: PropTypes.string, ["aria-label"]: PropTypes.string, ariaLabel: deprecate(PropTypes.string, "This prop syntax has been deprecated. Please use the new `aria-label`."), caption: PropTypes.string, children: PropTypes.node, className: PropTypes.string, closeOnEscape: PropTypes.bool, hasFocus: deprecate(PropTypes.bool, "hasFocus is deprecated. To conform to accessibility requirements hasFocus should always be `true` for ActionableNotification. If you were setting this prop to `false`, consider using the Callout component instead."), hideCloseButton: PropTypes.bool, inline: PropTypes.bool, kind: PropTypes.oneOf([ "error", "info", "info-square", "success", "warning", "warning-alt" ]), lowContrast: PropTypes.bool, onActionButtonClick: PropTypes.func, onClose: PropTypes.func, onCloseButtonClick: PropTypes.func, role: PropTypes.string, statusIconDescription: PropTypes.string, subtitle: PropTypes.node, title: PropTypes.string }; const mapping = { error: "warning", success: "info" }; const propMappingFunction = (deprecatedValue) => { return mapping[deprecatedValue]; }; function Callout({ actionButtonLabel, children, onActionButtonClick, title, titleId, subtitle, statusIconDescription, className, kind = "info", lowContrast, ...rest }) { const prefix = usePrefix(); const containerClassName = classNames(className, { [`${prefix}--actionable-notification`]: true, [`${prefix}--actionable-notification--low-contrast`]: lowContrast, [`${prefix}--actionable-notification--${kind}`]: kind, [`${prefix}--actionable-notification--hide-close-button`]: true }); const childrenContainer = useRef(null); useInteractiveChildrenNeedDescription(childrenContainer, `interactive child node(s) should have an \`aria-describedby\` property with a value matching the value of \`titleId\``); return /* @__PURE__ */ jsxs("div", { ...rest, className: containerClassName, children: [/* @__PURE__ */ jsxs("div", { className: `${prefix}--actionable-notification__details`, children: [/* @__PURE__ */ jsx(NotificationIcon, { notificationType: "inline", kind, iconDescription: statusIconDescription || `${kind} icon` }), /* @__PURE__ */ jsxs("div", { ref: childrenContainer, className: `${prefix}--actionable-notification__text-wrapper`, children: [ title && /* @__PURE__ */ jsx(Text, { as: "div", id: titleId, className: `${prefix}--actionable-notification__title`, children: title }), subtitle && /* @__PURE__ */ jsx(Text, { as: "div", className: `${prefix}--actionable-notification__subtitle`, children: subtitle }), children ] })] }), /* @__PURE__ */ jsx("div", { className: `${prefix}--actionable-notification__button-wrapper`, children: actionButtonLabel && /* @__PURE__ */ jsx(NotificationActionButton, { onClick: onActionButtonClick, "aria-describedby": titleId, inline: true, children: actionButtonLabel }) })] }); } Callout.propTypes = { actionButtonLabel: PropTypes.string, children: PropTypes.node, className: PropTypes.string, kind: deprecateValuesWithin(PropTypes.oneOf([ "error", "info", "info-square", "success", "warning", "warning-alt" ]), ["warning", "info"], propMappingFunction), lowContrast: PropTypes.bool, onActionButtonClick: PropTypes.func, statusIconDescription: PropTypes.string, subtitle: PropTypes.node, title: PropTypes.string, titleId: PropTypes.string }; let didWarnAboutDeprecation = false; const StaticNotification = (props) => { warning(didWarnAboutDeprecation, "`StaticNotification` has been renamed to `Callout`.Run the following codemod to automatically update usages in yourproject: `npx @carbon/upgrade migrate refactor-to-callout --write`"); didWarnAboutDeprecation = true; return /* @__PURE__ */ jsx(Callout, { ...props }); }; //#endregion export { ActionableNotification, Callout, InlineNotification, NotificationActionButton, NotificationButton, StaticNotification, ToastNotification };