@carbon/ibm-products
Version:
Carbon for IBM Products
495 lines (493 loc) • 21.8 kB
JavaScript
/**
* Copyright IBM Corp. 2020, 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 { __toESM } from "../../_virtual/_rolldown/runtime.js";
import { require_classnames } from "../../node_modules/classnames/index.js";
import { pkg } from "../../settings.js";
import { useClickOutside } from "../../global/js/hooks/useClickOutside.js";
import { usePresence } from "../../global/js/hooks/usePresence.js";
import { usePrefersReducedMotion } from "../../global/js/hooks/usePrefersReducedMotion.js";
import { getDevtoolsProps } from "../../global/js/utils/devtools.js";
import { prepareProps } from "../../global/js/utils/props-helper.js";
import { useId } from "../../global/js/utils/useId.js";
import { NotificationsEmptyState } from "../EmptyStates/NotificationsEmptyState/NotificationsEmptyState.js";
import { getSupportedLocale } from "../../global/js/utils/getSupportedLocale.js";
import { timeAgo } from "./utils.js";
import React, { useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { Button, Heading, IconButton, Link, Section, Toggle, usePrefix } from "@carbon/react";
import { CheckmarkFilled, ChevronDown, Close, ErrorFilled, InformationSquareFilled, Settings, WarningAltFilled } from "@carbon/react/icons";
import { dateTimeFormat } from "@carbon/utilities";
//#region src/components/NotificationsPanel/NotificationsPanel.tsx
/**
* Copyright IBM Corp. 2020, 2022
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
var import_classnames = /* @__PURE__ */ __toESM(require_classnames());
const componentName = "NotificationsPanel";
const blockClass = `${pkg.prefix}--notifications-panel`;
const DefaultLocale = "en-US";
const defaults = {
dateTimeStyle: "long",
daysAgoText: (value) => `${value} days ago`,
dismissAllLabel: "Dismiss all",
dismissSingleNotificationIconDescription: "Dismiss",
doNotDisturbLabel: "Do not disturb",
emptyStateLabel: "You do not have any notifications",
hourAgoText: (value) => `${value} hour ago`,
hoursAgoText: (value) => `${value} hours ago`,
illustrationTheme: "light",
minuteAgoText: (value) => `${value} minute ago`,
minutesAgoText: (value) => `${value} minutes ago`,
monthAgoText: (value) => `${value} month ago`,
monthsAgoText: (value) => `${value} months ago`,
nowText: "Now",
onDismissAllNotifications: () => {},
onDismissSingleNotification: () => {},
previousLabel: "Previous",
readLessLabel: "Read less",
readMoreLabel: "Read more",
secondsAgoText: (value) => `${value} seconds ago`,
settingsIconDescription: "Settings",
title: "Notifications",
todayLabel: "Today",
viewAllLabel: (value) => `View all (${value})`,
yearAgoText: (value) => `${value} year ago`,
yearsAgoText: (value) => `${value} years ago`,
yesterdayAtText: (value) => `Yesterday at ${value}`,
yesterdayLabel: "Yesterday"
};
/**
* The `NotificationsPanel` sets expectations on the behavior for notifications,
* allowing the user to view and interact with them all in one place.
*
* **To adopt the new localization:**
*
* **Step 1:** Provide a locale to the `dateTimeLocale` prop, such as "de" or "fr-CA".
*
* **Step 2:** Remove the now _deprecated_ props: `daysAgoText`,
* `hourAgoText`, `hoursAgoText`, `minuteAgoText`, `minutesAgoText`,
* `monthAgoText`, `monthsAgoText`, `nowText`, `secondsAgoText`,
* `yearAgoText`, `yearsAgoText`, `yesterdayAtText`.
*
* If you do not provide a locale, the deprecated props will be applied instead.
*/
const NotificationsPanel = React.forwardRef(({ className, data, dateTimeLocale, dateTimeStyle = defaults.dateTimeStyle, daysAgoText = defaults.daysAgoText, dismissAllLabel = defaults.dismissAllLabel, dismissSingleNotificationIconDescription = defaults.dismissSingleNotificationIconDescription, doNotDisturbDefaultToggled, doNotDisturbLabel = defaults.doNotDisturbLabel, emptyStateLabel = defaults.emptyStateLabel, hourAgoText = defaults.hourAgoText, hoursAgoText = defaults.hoursAgoText, illustrationTheme = defaults.illustrationTheme, minuteAgoText = defaults.minuteAgoText, minutesAgoText = defaults.minutesAgoText, monthAgoText = defaults.monthAgoText, monthsAgoText = defaults.monthsAgoText, nowText = defaults.nowText, onClickOutside, onDismissAllNotifications = defaults.onDismissAllNotifications, onDismissSingleNotification = defaults.onDismissSingleNotification, onDoNotDisturbChange, onSettingsClick, onViewAllClick, open, previousLabel = defaults.previousLabel, readLessLabel = defaults.readLessLabel, readMoreLabel = defaults.readMoreLabel, secondsAgoText = defaults.secondsAgoText, settingsIconDescription = defaults.settingsIconDescription, title = defaults.title, todayLabel = defaults.todayLabel, viewAllLabel = defaults.viewAllLabel, yearAgoText = defaults.yearAgoText, yearsAgoText = defaults.yearsAgoText, yesterdayAtText = defaults.yesterdayAtText, yesterdayLabel = defaults.yesterdayLabel, triggerButtonRef, ...rest }, ref) => {
const notificationPanelRef = useRef(null);
const notificationPanelInnerRef = useRef(null);
useRef(null);
useRef(null);
const [allNotifications, setAllNotifications] = useState([]);
const supportedLocale = getSupportedLocale(dateTimeLocale, DefaultLocale);
usePrefix();
const headingId = useId();
const isClickOnTrigger = useRef(false);
const { shouldRender } = usePresence(open, notificationPanelRef, usePrefersReducedMotion() ? "notifications-panel-exit-reduced" : "notifications-panel-fade-out");
useEffect(() => {
setAllNotifications(data);
}, [data]);
useEffect(() => {
const button = triggerButtonRef?.current;
const handleClick = () => {
isClickOnTrigger.current = true;
};
button?.addEventListener("click", handleClick, true);
return () => {
button?.removeEventListener("click", handleClick, true);
};
}, [triggerButtonRef]);
useClickOutside(ref || notificationPanelRef, (target) => {
const element = target;
if (!isClickOnTrigger.current) {
if (!isActionableElement(element)) setTimeout(() => {
triggerButtonRef?.current?.focus();
}, 100);
onClickOutside?.();
}
isClickOnTrigger.current = false;
});
const handleKeydown = (event) => {
event.stopPropagation();
if (event.key === "Escape") {
onClickOutside?.();
setTimeout(() => {
triggerButtonRef?.current?.focus();
}, 100);
}
};
useEffect(() => {
if (open) {
const observer = new MutationObserver(() => {
if (notificationPanelRef.current) {
notificationPanelRef.current?.querySelector(`.${blockClass}__dismiss-button`)?.focus();
observer.disconnect();
}
});
if (notificationPanelRef.current) (notificationPanelRef.current?.querySelector(`.${blockClass}__dismiss-button`))?.focus();
else observer.observe(document.body, {
childList: true,
subtree: true
});
return () => observer.disconnect();
}
}, [open]);
const sortChronologically = (arr) => {
if (!arr || arr && !arr.length) return;
return arr.sort((a, b) => b.timestamp - a.timestamp);
};
let yesterdayDate = /* @__PURE__ */ new Date();
yesterdayDate = new Date(yesterdayDate.setDate(yesterdayDate.getDate() - 1));
let dayBeforeYesterdayDate = /* @__PURE__ */ new Date();
dayBeforeYesterdayDate = new Date(dayBeforeYesterdayDate.setDate(dayBeforeYesterdayDate.getDate() - 2));
let withinLastDayNotifications = allNotifications && allNotifications.length && allNotifications.filter((item) => item.timestamp.getTime() >= yesterdayDate.getTime());
withinLastDayNotifications = sortChronologically(withinLastDayNotifications);
let previousDayNotifications = allNotifications && allNotifications.length && allNotifications.filter((item) => item.timestamp.getTime() < yesterdayDate.getTime() && item.timestamp.getTime() >= dayBeforeYesterdayDate.getTime());
previousDayNotifications = sortChronologically(previousDayNotifications);
let previousNotifications = allNotifications && allNotifications.length && allNotifications.filter((item) => item.timestamp.getTime() < dayBeforeYesterdayDate.getTime());
previousNotifications = sortChronologically(previousNotifications);
const renderDescription = (id) => {
const notification = allNotifications && allNotifications.length && allNotifications.filter((item) => item.id === id)[0];
const trimLength = 88;
const description = notification?.["description"];
const descriptionClassName = (0, import_classnames.default)([`${blockClass}__notification-description`, {
[`${blockClass}__notification-long-description`]: notification?.["showAll"],
[`${blockClass}__notification-short-description`]: !notification?.["showAll"]
}]);
const showMoreButtonClassName = (0, import_classnames.default)([{
[`${blockClass}__notification-read-less-button`]: notification?.["showAll"],
[`${blockClass}__notification-read-more-button`]: !notification?.["showAll"]
}]);
return /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: descriptionClassName }, description), description.length > trimLength && /* @__PURE__ */ React.createElement(Button, {
kind: "ghost",
size: "sm",
renderIcon: (props) => /* @__PURE__ */ React.createElement(ChevronDown, {
size: 16,
...props
}),
iconDescription: notification?.["showAll"] ? readLessLabel : readMoreLabel,
onClick: (event) => {
event.preventDefault();
event.stopPropagation();
setAllNotifications(allNotifications.map((item) => {
if (item.id === notification?.["id"]) return Object.assign({}, item, { showAll: !item?.["showAll"] });
return item;
}));
},
className: showMoreButtonClassName
}, notification?.["showAll"] ? readLessLabel : readMoreLabel));
};
const renderNotification = (group, notification, index) => {
const notificationClassName = (0, import_classnames.default)([`${blockClass}__notification`, `${blockClass}__notification-${group}`]);
const notificationHeaderClassName = (0, import_classnames.default)([`${blockClass}__notification-title`, { [`${blockClass}__notification-title-unread`]: notification.unread }]);
return /* @__PURE__ */ React.createElement(Section, {
key: `${notification.timestamp}-${notification.title}-${index}`,
className: notificationClassName,
as: "div",
role: "button",
tabIndex: 0,
onClick: () => notification.onNotificationClick(notification),
onKeyDown: (event) => {
if (event.target instanceof HTMLElement && event.target.classList.contains(`${blockClass}__dismiss-single-button`)) return;
if (event.which === 13) notification.onNotificationClick(notification);
}
}, notification.type === "error" && /* @__PURE__ */ React.createElement(ErrorFilled, {
size: 16,
className: (0, import_classnames.default)([`${blockClass}__notification-status-icon`, `${blockClass}__notification-status-icon-error`])
}), notification.type === "success" && /* @__PURE__ */ React.createElement(CheckmarkFilled, {
size: 16,
className: (0, import_classnames.default)([`${blockClass}__notification-status-icon`, `${blockClass}__notification-status-icon-success`])
}), notification.type === "warning" && /* @__PURE__ */ React.createElement(WarningAltFilled, {
size: 16,
className: (0, import_classnames.default)([`${blockClass}__notification-status-icon`, `${blockClass}__notification-status-icon-warning`])
}), notification.type === "informational" && /* @__PURE__ */ React.createElement(InformationSquareFilled, {
size: 16,
className: (0, import_classnames.default)([`${blockClass}__notification-status-icon`, `${blockClass}__notification-status-icon-informational`])
}), /* @__PURE__ */ React.createElement("div", { className: `${blockClass}__notification-content` }, /* @__PURE__ */ React.createElement("p", { className: `${blockClass}__notification-time-label` }, dateTimeLocale ? dateTimeFormat.relative.format(notification.timestamp, {
locale: supportedLocale,
style: dateTimeStyle
}) : timeAgo({
previousTime: notification.timestamp,
secondsAgoText,
minuteAgoText,
minutesAgoText,
hoursAgoText,
hourAgoText,
daysAgoText,
yesterdayAtText,
monthsAgoText,
monthAgoText,
yearsAgoText,
yearAgoText,
nowText
})), /* @__PURE__ */ React.createElement(Heading, { className: notificationHeaderClassName }, notification.title), notification.description && notification.description.length && renderDescription(notification.id), notification.link && notification.link.text && notification.link.url && /* @__PURE__ */ React.createElement(Link, {
href: notification.link.url,
className: `${blockClass}__notifications-link`,
...prepareProps({}, notification.link, ["text", "url"])
}, notification.link.text)), /* @__PURE__ */ React.createElement(IconButton, {
align: "left",
kind: "ghost",
size: "sm",
label: dismissSingleNotificationIconDescription,
className: `${blockClass}__dismiss-single-button`,
onClick: (event) => dismissSingleNotification(event, notification)
}, /* @__PURE__ */ React.createElement(Close, { size: 16 })));
};
const isActionableElement = (el) => {
if (!el) return false;
const interactiveRoles = new Set([
"button",
"link",
"textbox",
"checkbox",
"radio",
"slider",
"spinbutton",
"combobox",
"switch",
"menuitem"
]);
const actionableAncestor = el.closest("button, a, input, select, textarea, [tabindex], [contenteditable=\"true\"], [role]");
if (!actionableAncestor) return false;
return actionableAncestor instanceof HTMLButtonElement || actionableAncestor instanceof HTMLAnchorElement || actionableAncestor instanceof HTMLInputElement || actionableAncestor instanceof HTMLSelectElement || actionableAncestor instanceof HTMLTextAreaElement || actionableAncestor.tabIndex >= 0 || actionableAncestor.isContentEditable || interactiveRoles.has(actionableAncestor.getAttribute("role")?.toLowerCase() ?? "");
};
const dismissSingleNotification = (event, notification) => {
event.preventDefault();
event.stopPropagation();
onDismissSingleNotification(notification);
};
const mainSectionClassName = (0, import_classnames.default)([`${blockClass}__main-section`, { [`${blockClass}__main-section-empty`]: allNotifications && !allNotifications.length }]);
return shouldRender ? /* @__PURE__ */ React.createElement(Section, {
as: "div",
role: "dialog",
"aria-labelledby": headingId,
onKeyDown: handleKeydown,
...rest,
id: blockClass,
className: (0, import_classnames.default)(blockClass, className, `${blockClass}__container`, {
[`${blockClass}__entrance`]: open,
[`${blockClass}__exit`]: !open
}),
ref: ref || notificationPanelRef,
...getDevtoolsProps(componentName)
}, /* @__PURE__ */ React.createElement("div", { ref: notificationPanelInnerRef }, /* @__PURE__ */ React.createElement("div", { className: `${blockClass}__header-container` }, /* @__PURE__ */ React.createElement("div", { className: `${blockClass}__header-flex` }, /* @__PURE__ */ React.createElement(Heading, {
id: headingId,
className: `${blockClass}__header`
}, title), /* @__PURE__ */ React.createElement(Button, {
size: "sm",
kind: "ghost",
className: `${blockClass}__dismiss-button`,
onClick: onDismissAllNotifications
}, dismissAllLabel)), onDoNotDisturbChange && /* @__PURE__ */ React.createElement(Toggle, {
size: "sm",
className: `${blockClass}__do-not-disturb-toggle`,
id: `${blockClass}__do-not-disturb-toggle-component`,
labelA: doNotDisturbLabel,
labelB: doNotDisturbLabel,
onToggle: (event) => onDoNotDisturbChange(event),
defaultToggled: doNotDisturbDefaultToggled,
"aria-label": doNotDisturbLabel,
labelText: doNotDisturbLabel
})), /* @__PURE__ */ React.createElement(Section, { className: mainSectionClassName }, withinLastDayNotifications && withinLastDayNotifications.length ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Heading, { className: `${blockClass}__time-section-label` }, todayLabel), withinLastDayNotifications.map((notification, index) => renderNotification("today", notification, index))) : null, previousDayNotifications && previousDayNotifications.length ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Heading, { className: `${blockClass}__time-section-label` }, yesterdayLabel), previousDayNotifications.map((notification, index) => renderNotification("yesterday", notification, index))) : null, previousNotifications && previousNotifications.length ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Heading, { className: `${blockClass}__time-section-label` }, previousLabel), previousNotifications.map((notification, index) => renderNotification("previous", notification, index))) : null, !allNotifications.length && /* @__PURE__ */ React.createElement(NotificationsEmptyState, {
illustrationTheme,
title: "",
subtitle: emptyStateLabel
})), onViewAllClick && onSettingsClick && allNotifications && allNotifications.length > 0 && /* @__PURE__ */ React.createElement("div", { className: `${blockClass}__bottom-actions` }, /* @__PURE__ */ React.createElement(Button, {
kind: "ghost",
className: `${blockClass}__view-all-button`,
onClick: onViewAllClick
}, viewAllLabel(allNotifications.length)), /* @__PURE__ */ React.createElement(Button, {
kind: "ghost",
size: "sm",
className: `${blockClass}__settings-button`,
renderIcon: (props) => /* @__PURE__ */ React.createElement(Settings, {
size: 16,
...props
}),
iconDescription: settingsIconDescription,
onClick: onSettingsClick,
hasIconOnly: true,
tooltipPosition: "left"
})))) : null;
});
NotificationsPanel.displayName = componentName;
NotificationsPanel.propTypes = {
/**
* Provide an optional class to be applied to the containing node.
*/
className: PropTypes.string,
/**
* Array of data for Notifications component to render
*/
/**@ts-ignore*/
data: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
type: PropTypes.oneOf([
"error",
"warning",
"success",
"informational"
]),
timestamp: PropTypes.instanceOf(Date),
title: PropTypes.string,
description: PropTypes.string,
link: PropTypes.shape({
url: PropTypes.string,
text: PropTypes.string
}),
unread: PropTypes.bool,
onNotificationClick: PropTypes.func
})).isRequired,
/**
* The language for each notification's time stamp.
*/
dateTimeLocale: PropTypes.string,
/**
* The date/time format for each notification's time stamp.
*
* E.g. `long` as "6 minutes ago", `short` as "6m ago".
*/
dateTimeStyle: PropTypes.string,
/**
* Sets the `days ago` label text
*/
daysAgoText: PropTypes.func,
/**
* Label for Dismiss all button
*/
dismissAllLabel: PropTypes.string,
/**
* Label for Dismiss single notification icon button
*/
dismissSingleNotificationIconDescription: PropTypes.string,
/**
* Optional: Determines if the `Do not disturb` toggle is on or off when the component is rendered
*/
doNotDisturbDefaultToggled: PropTypes.bool,
/**
* Optional: Label for Do not disturb toggle
*/
doNotDisturbLabel: PropTypes.string,
/**
* Sets the empty state label text when there are no notifications
*/
emptyStateLabel: PropTypes.string,
/**
* Sets the `hour ago` label text
*/
hourAgoText: PropTypes.func,
/**
* Sets the `hours ago` label text
*/
hoursAgoText: PropTypes.func,
/**
* Determines the theme of the empty state's illustration.
*/
illustrationTheme: PropTypes.oneOf(["light", "dark"]),
/**
* Sets the `minute ago` label text
*/
minuteAgoText: PropTypes.func,
/**
* Sets the `minutes ago` label text
*/
minutesAgoText: PropTypes.func,
/**
* Sets the `month ago` label text
*/
monthAgoText: PropTypes.func,
/**
* Sets the `months ago` label text
*/
monthsAgoText: PropTypes.func,
/**
* Sets the `now` label text
*/
nowText: PropTypes.string,
/**
* Optional function called after clicking outside of the panel.
*/
onClickOutside: PropTypes.func,
/**
* Optional function called after clicking the "Dismiss all" button.
*/
onDismissAllNotifications: PropTypes.func,
/**
* Optional function called after clicking a notification's "X" button.
*/
onDismissSingleNotification: PropTypes.func,
/**
* Optional function called after toggling "Do not disturb".
*/
onDoNotDisturbChange: PropTypes.func,
/**
* Optional function called after clicking settings / gear icon button.
*/
onSettingsClick: PropTypes.func,
/**
* Optional function called after clicking the "View all" button.
*/
onViewAllClick: PropTypes.func,
/**
* Determines whether the notifications panel should render or not
*/
open: PropTypes.bool.isRequired,
/**
* Sets the previous label text
*/
previousLabel: PropTypes.string,
/**
* Sets the `read less` label text
*/
readLessLabel: PropTypes.string,
/**
* Sets the `read more` label text
*/
readMoreLabel: PropTypes.string,
/**
* Sets the `seconds ago` label text
*/
secondsAgoText: PropTypes.func,
/**
* Sets the settings icon description text
*/
settingsIconDescription: PropTypes.string,
/**
* Sets the title for the Notifications panel
*/
title: PropTypes.string,
/**
* Sets the today label text
*/
todayLabel: PropTypes.string,
/**
* Sets the today label text
*/
triggerButtonRef: PropTypes.any,
/**
* Sets the View all button text
*/
viewAllLabel: PropTypes.func,
/**
* Sets the `year ago` label text
*/
yearAgoText: PropTypes.func,
/**
* Sets the `years ago` label text
*/
yearsAgoText: PropTypes.func,
/**
* Sets the `Yesterday at` label text
*/
yesterdayAtText: PropTypes.func,
/**
* Sets the yesterday label text
*/
yesterdayLabel: PropTypes.string
};
//#endregion
export { NotificationsPanel };