@carbon/react
Version:
React components for the Carbon Design System
547 lines (545 loc) • 19.4 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 { 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 };