UNPKG

@carbon/react

Version:

React components for the Carbon Design System

822 lines (803 loc) 26.3 kB
/** * Copyright IBM Corp. 2016, 2023 * * 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 { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js'; import PropTypes from 'prop-types'; import React, { useState, useRef, useEffect } from 'react'; import { deprecate } from '../../prop-types/deprecate.js'; import cx from 'classnames'; import { Close, InformationSquareFilled, InformationFilled, WarningAltFilled, WarningFilled, CheckmarkFilled, ErrorFilled } from '@carbon/icons-react'; import '../Text/index.js'; import Button from '../Button/Button.js'; import '../Button/Button.Skeleton.js'; import useIsomorphicEffect from '../../internal/useIsomorphicEffect.js'; import { useNoInteractiveChildren, useInteractiveChildrenNeedDescription } from '../../internal/useNoInteractiveChildren.js'; import { Tab, Escape } from '../../internal/keyboard/keys.js'; import { match, matches } from '../../internal/keyboard/match.js'; import { usePrefix } from '../../internal/usePrefix.js'; import { useId } from '../../internal/useId.js'; import { noopFn } from '../../internal/noopFn.js'; import { wrapFocusWithoutSentinels, wrapFocus } from '../../internal/wrapFocus.js'; import { useFeatureFlag } from '../FeatureFlags/index.js'; import { warning } from '../../internal/warning.js'; import deprecateValuesWithin from '../../prop-types/deprecateValuesWithin.js'; import { Text } from '../Text/Text.js'; /** * 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 => { // The callback should only be called when focus is on or within the container 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 }) { const prefix = usePrefix(); const className = cx(customClassName, { [`${prefix}--actionable-notification__action-button`]: true }); return /*#__PURE__*/React.createElement(Button, _extends({ className: className, kind: inline ? 'ghost' : 'tertiary', onClick: onClick, size: "sm" }, rest), children); } NotificationActionButton.propTypes = { /** * Specify the content of the notification action button. */ children: PropTypes.node, /** * Specify an optional className to be applied to the notification action button */ className: PropTypes.string, /** * Specify if the visual treatment of the button should be for an inline notification */ inline: PropTypes.bool, /** * Optionally specify a click handler for the notification action button. */ onClick: PropTypes.func }; /** * NotificationButton * ================== */ function NotificationButton({ 'aria-label': ariaLabel = 'close notification', ariaLabel: deprecatedAriaLabel, className, type = 'button', renderIcon: IconTag = Close, name, notificationType = 'toast', ...rest }) { const prefix = usePrefix(); const buttonClassName = cx(className, { [`${prefix}--${notificationType}-notification__close-button`]: notificationType }); const iconClassName = cx({ [`${prefix}--${notificationType}-notification__close-icon`]: notificationType }); return /*#__PURE__*/React.createElement("button", _extends({}, rest, { // eslint-disable-next-line react/button-has-type type: type, "aria-label": deprecatedAriaLabel || ariaLabel, title: deprecatedAriaLabel || ariaLabel, className: buttonClassName }), IconTag && /*#__PURE__*/React.createElement(IconTag, { className: iconClassName, name: name })); } NotificationButton.propTypes = { /** * Specify a label to be read by screen readers on the container node */ ['aria-label']: PropTypes.string, /** * Deprecated, please use `aria-label` instead. * Specify a label to be read by screen readers on the container note. */ ariaLabel: deprecate(PropTypes.string, 'This prop syntax has been deprecated. Please use the new `aria-label`.'), /** * Specify an optional className to be applied to the notification button */ className: PropTypes.string, /** * Specify an optional icon for the Button through a string, * if something but regular "close" icon is desirable */ name: PropTypes.string, /** * Specify the notification type */ notificationType: PropTypes.oneOf(['toast', 'inline', 'actionable']), /** * A component used to render an icon. */ renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), /** * Optional prop to specify the type of the Button */ 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__*/React.createElement(IconForKind, { className: `${prefix}--${notificationType}-notification__icon`, size: 20 }, /*#__PURE__*/React.createElement("title", null, 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 }; /** * ToastNotification * ================= */ function ToastNotification({ ['aria-label']: ariaLabel, // @ts-expect-error: deprecated prop 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 = cx(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__*/React.createElement("div", _extends({ ref: ref }, rest, { role: role, className: containerClassName }), /*#__PURE__*/React.createElement(NotificationIcon, { notificationType: "toast", kind: kind, iconDescription: statusIconDescription || `${kind} icon` }), /*#__PURE__*/React.createElement("div", { ref: contentRef, className: `${prefix}--toast-notification__details` }, title && /*#__PURE__*/React.createElement(Text, { as: "div", className: `${prefix}--toast-notification__title` }, title), subtitle && /*#__PURE__*/React.createElement(Text, { as: "div", className: `${prefix}--toast-notification__subtitle` }, subtitle), caption && /*#__PURE__*/React.createElement(Text, { as: "div", className: `${prefix}--toast-notification__caption` }, caption), children), !hideCloseButton && /*#__PURE__*/React.createElement(NotificationButton, { notificationType: "toast", onClick: handleCloseButtonClick, "aria-label": deprecatedAriaLabel || ariaLabel })); } ToastNotification.propTypes = { /** * Provide a description for "close" icon button that can be read by screen readers */ ['aria-label']: PropTypes.string, /** * Deprecated, please use `aria-label` instead. * Provide a description for "close" icon button that can be read by screen readers */ ariaLabel: deprecate(PropTypes.string, 'This prop syntax has been deprecated. Please use the new `aria-label`.'), /** * Specify the caption */ caption: PropTypes.string, /** * Specify the content */ children: PropTypes.node, /** * Specify an optional className to be applied to the notification box */ className: PropTypes.string, /** * Specify the close button should be disabled, or not */ hideCloseButton: PropTypes.bool, /** * Specify what state the notification represents */ kind: PropTypes.oneOf(['error', 'info', 'info-square', 'success', 'warning', 'warning-alt']), /** * Specify whether you are using the low contrast variant of the ToastNotification. */ lowContrast: PropTypes.bool, /** * Provide a function that is called when menu is closed */ onClose: PropTypes.func, /** * Provide a function that is called when the close button is clicked */ onCloseButtonClick: PropTypes.func, /** * By default, this value is "status". You can also provide an alternate * role if it makes sense from the accessibility-side */ role: PropTypes.oneOf(['alert', 'log', 'status']), /** * Provide a description for "status" icon that can be read by screen readers */ statusIconDescription: PropTypes.string, /** * Specify the subtitle */ subtitle: PropTypes.string, /** * Specify an optional duration the notification should be closed in */ timeout: PropTypes.number, /** * Specify the title */ title: PropTypes.string }; /** * InlineNotification * ================== */ 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 = cx(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__*/React.createElement("div", _extends({ ref: ref }, rest, { role: role, className: containerClassName }), /*#__PURE__*/React.createElement("div", { className: `${prefix}--inline-notification__details` }, /*#__PURE__*/React.createElement(NotificationIcon, { notificationType: "inline", kind: kind, iconDescription: statusIconDescription || `${kind} icon` }), /*#__PURE__*/React.createElement("div", { ref: contentRef, className: `${prefix}--inline-notification__text-wrapper` }, title && /*#__PURE__*/React.createElement(Text, { as: "div", className: `${prefix}--inline-notification__title` }, title), subtitle && /*#__PURE__*/React.createElement(Text, { as: "div", className: `${prefix}--inline-notification__subtitle` }, subtitle), children)), !hideCloseButton && /*#__PURE__*/React.createElement(NotificationButton, { notificationType: "inline", onClick: handleCloseButtonClick, "aria-label": ariaLabel })); } InlineNotification.propTypes = { /** * Provide a description for "close" icon button that can be read by screen readers */ ['aria-label']: PropTypes.string, /** * Specify the content */ children: PropTypes.node, /** * Specify an optional className to be applied to the notification box */ className: PropTypes.string, /** * Specify the close button should be disabled, or not */ hideCloseButton: PropTypes.bool, /** * Specify what state the notification represents */ kind: PropTypes.oneOf(['error', 'info', 'info-square', 'success', 'warning', 'warning-alt']), /** * Specify whether you are using the low contrast variant of the InlineNotification. */ lowContrast: PropTypes.bool, /** * Provide a function that is called when menu is closed */ onClose: PropTypes.func, /** * Provide a function that is called when the close button is clicked */ onCloseButtonClick: PropTypes.func, /** * By default, this value is "status". You can also provide an alternate * role if it makes sense from the accessibility-side. */ role: PropTypes.oneOf(['alert', 'log', 'status']), /** * Provide a description for "status" icon that can be read by screen readers */ statusIconDescription: PropTypes.string, /** * Specify the subtitle */ subtitle: PropTypes.string, /** * Specify the title */ title: PropTypes.string }; /** * ActionableNotification * ====================== */ function ActionableNotification({ actionButtonLabel, ['aria-label']: ariaLabel, // @ts-expect-error: deprecated prop 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 = cx(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 focusTrapWithoutSentinels = useFeatureFlag('enable-experimental-focus-wrap-without-sentinels'); useIsomorphicEffect(() => { if (hasFocus && role === 'alertdialog') { const button = document.querySelector('button.cds--actionable-notification__action-button'); 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 }); } } 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__*/React.createElement("div", _extends({}, rest, { ref: ref, role: role, className: containerClassName, "aria-labelledby": title ? id : subtitleId, onBlur: !focusTrapWithoutSentinels ? handleBlur : () => {}, onKeyDown: focusTrapWithoutSentinels ? handleKeyDown : () => {} }), !focusTrapWithoutSentinels && /*#__PURE__*/React.createElement("span", { ref: startTrap, tabIndex: 0, role: "link", className: `${prefix}--visually-hidden` }, "Focus sentinel"), /*#__PURE__*/React.createElement("div", { className: `${prefix}--actionable-notification__details` }, /*#__PURE__*/React.createElement(NotificationIcon, { notificationType: inline ? 'inline' : 'toast', kind: kind, iconDescription: statusIconDescription || `${kind} icon` }), /*#__PURE__*/React.createElement("div", { className: `${prefix}--actionable-notification__text-wrapper` }, /*#__PURE__*/React.createElement("div", { className: `${prefix}--actionable-notification__content` }, title && /*#__PURE__*/React.createElement(Text, { as: "div", className: `${prefix}--actionable-notification__title`, id: id }, title), subtitle && /*#__PURE__*/React.createElement(Text, { as: "div", className: `${prefix}--actionable-notification__subtitle`, id: subtitleId }, subtitle), caption && /*#__PURE__*/React.createElement(Text, { as: "div", className: `${prefix}--actionable-notification__caption` }, caption), children))), /*#__PURE__*/React.createElement("div", { className: `${prefix}--actionable-notification__button-wrapper`, ref: innerModal }, actionButtonLabel && /*#__PURE__*/React.createElement(NotificationActionButton, { onClick: onActionButtonClick, inline: inline }, actionButtonLabel), !hideCloseButton && /*#__PURE__*/React.createElement(NotificationButton, { "aria-label": deprecatedAriaLabel || ariaLabel, notificationType: "actionable", onClick: handleCloseButtonClick })), !focusTrapWithoutSentinels && /*#__PURE__*/React.createElement("span", { ref: endTrap, tabIndex: 0, role: "link", className: `${prefix}--visually-hidden` }, "Focus sentinel")); } ActionableNotification.propTypes = { /** * Pass in the action button label that will be rendered within the ActionableNotification. */ actionButtonLabel: PropTypes.string, /** * Provide a description for "close" icon button that can be read by screen readers */ ['aria-label']: PropTypes.string, /** * Deprecated, please use `aria-label` instead. * Provide a description for "close" icon button that can be read by screen readers */ ariaLabel: deprecate(PropTypes.string, 'This prop syntax has been deprecated. Please use the new `aria-label`.'), /** * Specify the caption */ caption: PropTypes.string, /** * Specify the content */ children: PropTypes.node, /** * Specify an optional className to be applied to the notification box */ className: PropTypes.string, /** * Specify if pressing the escape key should close notifications */ closeOnEscape: PropTypes.bool, /** * Specify if focus should be moved to the component when the notification contains actions */ 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.'), /** * Specify the close button should be disabled, or not */ hideCloseButton: PropTypes.bool, /* * Specify if the notification should have inline styling applied instead of toast */ inline: PropTypes.bool, /** * Specify what state the notification represents */ kind: PropTypes.oneOf(['error', 'info', 'info-square', 'success', 'warning', 'warning-alt']), /** * Specify whether you are using the low contrast variant of the ActionableNotification. */ lowContrast: PropTypes.bool, /** * Provide a function that is called when the action is clicked */ onActionButtonClick: PropTypes.func, /** * Provide a function that is called when menu is closed */ onClose: PropTypes.func, /** * Provide a function that is called when the close button is clicked */ onCloseButtonClick: PropTypes.func, /** * Provide an accessible role to be used. Defaults to `alertdialog`. Any other * value will disable the wrapping of focus. To remain accessible, additional * work is required. See the storybook docs for more info: * https://react.carbondesignsystem.com/?path=/docs/components-notifications-actionable--overview#using-the-role-prop */ role: PropTypes.string, /** * Provide a description for "status" icon that can be read by screen readers */ statusIconDescription: PropTypes.string, /** * Specify the subtitle */ subtitle: PropTypes.node, /** * Specify the title */ title: PropTypes.string }; /** * Callout * ================== */ /** * Deprecated callout kind values. * @deprecated Use NewKindProps instead. */ const propMappingFunction = deprecatedValue => { const mapping = { error: 'warning', // only redirect error -> warning success: 'info' // only redirect success -> info }; return mapping[deprecatedValue]; }; function Callout({ actionButtonLabel, children, onActionButtonClick, title, titleId, subtitle, statusIconDescription, className, kind = 'info', lowContrast, ...rest }) { const prefix = usePrefix(); const containerClassName = cx(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__*/React.createElement("div", _extends({}, rest, { className: containerClassName }), /*#__PURE__*/React.createElement("div", { className: `${prefix}--actionable-notification__details` }, /*#__PURE__*/React.createElement(NotificationIcon, { notificationType: "inline", kind: kind, iconDescription: statusIconDescription || `${kind} icon` }), /*#__PURE__*/React.createElement("div", { ref: childrenContainer, className: `${prefix}--actionable-notification__text-wrapper` }, title && /*#__PURE__*/React.createElement(Text, { as: "div", id: titleId, className: `${prefix}--actionable-notification__title` }, title), subtitle && /*#__PURE__*/React.createElement(Text, { as: "div", className: `${prefix}--actionable-notification__subtitle` }, subtitle), children)), /*#__PURE__*/React.createElement("div", { className: `${prefix}--actionable-notification__button-wrapper` }, actionButtonLabel && /*#__PURE__*/React.createElement(NotificationActionButton, { onClick: onActionButtonClick, "aria-describedby": titleId, inline: true }, actionButtonLabel))); } Callout.propTypes = { /** * Pass in the action button label that will be rendered within the ActionableNotification. */ actionButtonLabel: PropTypes.string, /** * Specify the content */ children: PropTypes.node, /** * Specify an optional className to be applied to the notification box */ className: PropTypes.string, /** * Specify what state the notification represents */ kind: deprecateValuesWithin(PropTypes.oneOf(['error', 'info', 'info-square', 'success', 'warning', 'warning-alt']), ['warning', 'info'], propMappingFunction), /** * Specify whether you are using the low contrast variant of the Callout. */ lowContrast: PropTypes.bool, /** * Provide a function that is called when the action is clicked */ onActionButtonClick: PropTypes.func, /** * Provide a description for "status" icon that can be read by screen readers */ statusIconDescription: PropTypes.string, /** * Specify the subtitle */ subtitle: PropTypes.node, /** * Specify the title */ title: PropTypes.string, /** * Specify the id for the element containing the title */ titleId: PropTypes.string }; // In renaming StaticNotification to Callout, the legacy StaticNotification // export and it's types should remain usable until Callout is moved to stable. // The StaticNotification component below forwards props to Callout and inherits // CalloutProps to ensure consumer usage is not impacted, while providing them // a deprecation warning. // TODO: remove this when Callout moves to stable OR in v12, whichever is first /** * @deprecated Use `CalloutProps` instead. */ let didWarnAboutDeprecation = false; const StaticNotification = props => { if (process.env.NODE_ENV !== 'production') { process.env.NODE_ENV !== "production" ? warning(didWarnAboutDeprecation, '`StaticNotification` has been renamed to `Callout`.' + 'Run the following codemod to automatically update usages in your' + 'project: `npx @carbon/upgrade migrate refactor-to-callout --write`') : void 0; didWarnAboutDeprecation = true; } return /*#__PURE__*/React.createElement(Callout, props); }; export { ActionableNotification, Callout, InlineNotification, NotificationActionButton, NotificationButton, StaticNotification, ToastNotification };