UNPKG

@carbon/ibm-products

Version:
495 lines (493 loc) 21.8 kB
/** * 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 };