@carbon/react
Version:
React components for the Carbon Design System
822 lines (803 loc) • 26.3 kB
JavaScript
/**
* 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 };