src/toast/toast.tsx (229 lines of code) (raw):
/*
Copyright (c) Uber Technologies, Inc.
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* global document */
import * as React from 'react';
import { getOverrides, mergeOverrides } from '../helpers/overrides';
import DeleteIcon from '../icon/delete';
import {
Body as StyledBody,
CloseIconSvg as StyledCloseIcon,
InnerContainer as StyledInnerContainer,
} from './styled-components';
import { KIND, TYPE } from './constants';
import { LocaleContext } from '../locale';
import type { ToastProps, ToastPropsShape, ToastPrivateState, SharedStylePropsArg } from './types';
import type { IconOverrides } from '../icon';
import { isFocusVisible, forkFocus, forkBlur } from '../utils/focusVisible';
class Toast extends React.Component<ToastProps, ToastPrivateState> {
static defaultProps: ToastPropsShape = {
autoFocus: false,
autoHideDuration: 0,
closeable: true,
kind: KIND.info,
notificationType: TYPE.toast,
// Do we need a separate handler for
// when a notification dismisses automatically
onClose: () => {},
onBlur: () => {},
onFocus: () => {},
onMouseEnter: () => {},
onMouseLeave: () => {},
overrides: {},
};
autoHideTimeout: ReturnType<typeof setTimeout> | undefined | null;
animateInTimer: ReturnType<typeof setTimeout> | undefined | null;
animateOutCompleteTimer: ReturnType<typeof setTimeout> | undefined | null;
closeRef:
| {
current: SVGSVGElement | undefined | null;
}
| undefined
| null;
previouslyFocusedElement: SVGElement | HTMLElement | undefined | null;
state = {
isVisible: false,
isRendered: true,
isFocusVisible: false,
};
constructor(props: ToastProps) {
super(props);
this.closeRef = React.createRef();
this.previouslyFocusedElement = null;
}
componentDidMount() {
this.animateIn();
this.startTimeout();
if (
__BROWSER__ &&
this.props.autoFocus &&
this.closeRef &&
this.closeRef.current &&
this.closeRef.current.focus &&
typeof this.closeRef.current.focus === 'function'
) {
// todo(flow->ts): double check if typecast is correct
this.previouslyFocusedElement = document.activeElement as HTMLElement | SVGElement;
this.closeRef.current.focus();
this.setState({ isFocusVisible: true });
}
}
componentDidUpdate(prevProps: ToastProps) {
if (
this.props.autoHideDuration !== prevProps.autoHideDuration ||
this.props.__updated !== prevProps.__updated
) {
this.startTimeout();
}
}
componentWillUnmount() {
this.clearTimeout();
}
handleFocus = (event: React.FocusEvent) => {
if (isFocusVisible(event)) {
this.setState({ isFocusVisible: true });
}
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
handleBlur = (event: React.FocusEvent) => {
if (this.state.isFocusVisible !== false) {
this.setState({ isFocusVisible: false });
}
};
startTimeout() {
if (this.props.autoHideDuration) {
if (this.autoHideTimeout) {
clearTimeout(this.autoHideTimeout);
}
this.autoHideTimeout = setTimeout(this.dismiss, this.props.autoHideDuration);
}
}
clearTimeout() {
[this.autoHideTimeout, this.animateInTimer, this.animateOutCompleteTimer].forEach((timerId) => {
if (timerId) {
clearTimeout(timerId);
}
});
}
animateIn = () => {
// Defer to next event loop
this.animateInTimer = setTimeout(() => {
this.setState({ isVisible: true });
}, 0);
};
animateOut = (callback: () => unknown = () => {}) => {
this.setState({ isVisible: false });
// Remove the toast from the DOM after animation finishes
this.animateOutCompleteTimer = setTimeout(() => {
this.setState({ isRendered: false });
callback();
}, 600);
};
dismiss = () => {
this.animateOut(this.props.onClose);
if (this.props.autoFocus && this.previouslyFocusedElement) {
this.previouslyFocusedElement.focus();
}
};
onFocus = (e: React.FocusEvent) => {
if (!this.state.isVisible) return;
// @ts-ignore
clearTimeout(this.autoHideTimeout);
// @ts-ignore
clearTimeout(this.animateOutCompleteTimer);
typeof this.props.onFocus === 'function' && this.props.onFocus(e);
};
onMouseEnter = (e: React.MouseEvent) => {
if (!this.state.isVisible) return;
// @ts-ignore
clearTimeout(this.autoHideTimeout);
// @ts-ignore
clearTimeout(this.animateOutCompleteTimer);
typeof this.props.onMouseEnter === 'function' && this.props.onMouseEnter(e);
};
onBlur = (e: React.FocusEvent) => {
this.startTimeout();
typeof this.props.onBlur === 'function' && this.props.onBlur(e);
};
onMouseLeave = (e: React.MouseEvent) => {
this.startTimeout();
typeof this.props.onMouseLeave === 'function' && this.props.onMouseLeave(e);
};
getSharedProps(): Partial<SharedStylePropsArg> {
const { kind, notificationType, closeable } = this.props;
const { isRendered, isVisible } = this.state;
return {
$kind: kind,
$type: notificationType,
$closeable: closeable,
$isRendered: isRendered,
$isVisible: isVisible,
};
}
render() {
const { children, closeable, autoFocus } = this.props;
const isAlertDialog = closeable && autoFocus;
const { isRendered } = this.state;
const {
// @ts-ignore
Body: BodyOverride,
// @ts-ignore
CloseIcon: CloseIconOverride,
// @ts-ignore
InnerContainer: InnerContainerOverride,
} = this.props.overrides;
const [Body, bodyProps] = getOverrides(BodyOverride, StyledBody);
const [InnerContainer, innerContainerProps] = getOverrides(
InnerContainerOverride,
StyledInnerContainer
);
const [CloseIcon, closeIconProps] = getOverrides(CloseIconOverride, StyledCloseIcon);
const closeIconOverrides: IconOverrides = mergeOverrides(
{ Svg: { component: CloseIcon } },
{ Svg: CloseIconOverride }
);
const sharedProps = this.getSharedProps();
if (!isRendered) {
return null;
}
// Default role is alert unless given a role in props or the toast has an autofocus close button
const role = this.props.hasOwnProperty('role')
? this.props.role
: isAlertDialog
? 'alertdialog'
: 'alert';
const ariaLive =
(!this.props.hasOwnProperty('role') && isAlertDialog) || this.props.role == 'alertdialog'
? 'assertive'
: this.props.role == 'alert' || !this.props.hasOwnProperty('role')
? undefined // adding both aria-live and role="alert" causes double speaking issues
: 'polite';
return (
<LocaleContext.Consumer>
{(locale) => (
<Body
role={role}
data-baseweb={this.props['data-baseweb'] || 'toast'}
{...sharedProps}
{...bodyProps}
aria-atomic={true}
aria-live={ariaLive}
// the properties below have to go after overrides
onBlur={this.onBlur}
onFocus={this.onFocus}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
<InnerContainer {...sharedProps} {...innerContainerProps}>
{typeof children === 'function' ? children({ dismiss: this.dismiss }) : children}
</InnerContainer>
{closeable ? (
<DeleteIcon
// @ts-ignore
ref={this.closeRef}
aria-hidden={true}
role="button"
tabIndex={-1}
$isFocusVisible={this.state.isFocusVisible}
onClick={this.dismiss}
onKeyPress={(event) => {
if (event.key === 'Enter') {
this.dismiss();
}
}}
title={locale.toast.close}
{...sharedProps}
{...closeIconProps}
onFocus={forkFocus(closeIconProps, this.handleFocus)}
onBlur={forkBlur(closeIconProps, this.handleBlur)}
overrides={closeIconOverrides}
/>
) : null}
</Body>
)}
</LocaleContext.Consumer>
);
}
}
export default Toast;